Compare commits

...

121 Commits

Author SHA1 Message Date
Marco 8a4170a1e6 Add DHL checkout integrations (autocomplete, postnummer, return label)
Wires the new backend DHL endpoints into checkout: an address-autocomplete
dropdown on the street fields, live Postnummer validation for Packstation
delivery, and a return-label download link on the order-detail page.
Proxied through Next.js API routes since DHL credentials are tenant-
specific and CheckoutContent is a Client Component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:01:05 +00:00
Marco 51e4860fe3 Add spotlight wishlist toggle and shop filter/grid polish
Per-product spotlightShowWishlist opt-in (independent of the global
wishlistEnabled toggle), a reveal-on-hover WishlistButton variant for
multi-card grids (avoids a heart on every card reading as visual noise),
and related PriceRangeFilter/ProductGrid/CustomSelect adjustments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:00:55 +00:00
Marco 8c2220ae45 Document tonight's wishlist/search/filters/CustomSelect work in README
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 23:24:54 +00:00
Marco ee39a5d7dc Use CustomSelect for checkout's country pickers too
Promoted CustomSelect from konto/bestellungen/components/ to a shared
app/components/ location. New includeAllOption (checkout doesn't want
a "clear" pseudo-option — a country is always genuinely selected) and
fullWidth (matches the other w-full form fields, no sm: shrink) props.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 23:15:08 +00:00
Marco 2b2ee4d23c Gate order-list filters behind CompanySettings.orderFilterEnabled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 23:12:54 +00:00
Marco f5b898a69a Gate shop/blog filters behind CompanySettings toggles; fix blog filter z-index bug
Fixed: blog category filter bar was rendering behind the featured-post
card — that card pulls itself up (-mt-8, z-10) to overlap the hero's
bottom edge (its original design), but inserting the filter bar
between the hero and that card meant the same pull now overlapped the
filter bar instead, with the card's stacking rendering on top. Given
relative z-20 to stay above that overlap regardless. Also bumped the
inactive chip's background to bg-bg-muted — border-border on bg-base
is only a ~2% lightness difference, nearly invisible as a pill outline.

Both filters (shop price, blog category) now gated behind new
CompanySettings toggles (shopFilterEnabled/blogFilterEnabled), same
off-by-default pattern as wishlistEnabled/searchEnabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 23:07:49 +00:00
Marco fc8a1d4201 Add standalone registration page; fix 2 merkliste bugs; honor login redirect
New /konto/registrieren (+ RegisterForm.tsx) — until now an account
could only be created inline during checkout, which stopped making
sense the moment the Wishlist gave accounts a non-purchase use case.
LoginForm now also honors ?redirect= (previously ignored, always
landing on /konto/bestellungen regardless of where the login was
triggered from, e.g. WishlistButton's login-gate) and links to the
new registration page instead of implying "only via checkout".

Fixed: removing an item from the wishlist didn't disappear from
/konto/merkliste — that page was a Server Component with a fixed
render, not reactive to the client-side toggle. New MerklisteGrid.tsx
(Client Component) uses useWishlist()'s live state as the actual
source of truth for which products are still shown.

Fixed: AddToCartInlineButton looked broken on that same page —
`className="w-full"` was passed to it, but its `className` prop
*replaces* the whole default styling (`?? default`, not a merge),
throwing away all the button's actual styling. Its default is
already `w-full`; no override needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:59:23 +00:00
Marco bce5a9f82c Replace shop price toggle-badges with a real min/max range filter
Preset price buckets as toggle chips were reverted per feedback
("keine toggle badges") — PriceRangeFilter.tsx is a plain min/max
number-input pair instead, still a URL search param underneath
(?minPrice=&maxPrice=), same shareable/bookmarkable approach.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:53:51 +00:00
Marco bc40e22910 Gate search behind CompanySettings.searchEnabled; fix two reported bugs
Search icon now gated behind the new backend toggle, same pattern as
wishlistEnabled — off by default.

Fixed: blog category filter bar was invisible — it was wrapped in
<Reveal>, which sits right at/just past the hero's bottom edge, the
exact "already near the initial viewport" position Reveal.tsx's own
comment documents as a whileInView(margin:"-80px") trap (an element
already visible without scrolling can permanently never register as
"entered view", since `once: true` never gets a second chance). Now a
plain div, no scroll-reveal animation needed for a filter bar anyway.

Fixed: wishlist count showing one behind on the Navbar badge —
useWishlist.ts's toggle() broadcast the "refetch me" event immediately
after the optimistic local update, before the POST request was even
sent. Another instance's resulting refetch could race the actual
server-side write, get the pre-toggle list, and then never get told to
refetch again. Broadcast now only fires after the request settles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:51:37 +00:00
Marco 2eda211a29 Add instant-search overlay (products + blog posts)
Separate commit on purpose so this can be reverted independently if
needed. Search icon in the Navbar (hidden below sm:, same reasoning
as WishlistLink — Account+Cart are the only always-visible icons on
true mobile) opens a debounced (250ms) overlay searching both
collections at once via a new /api/search route.

Plain Payload `where[...][contains]` queries (Postgres ILIKE), not a
real search index (Meilisearch/Algolia) — matches the catalog's
current small size, see [[project-ecommerce-sota-gaps]]'s own "search
becomes necessary past ~20 products" note. Worth upgrading later
without touching the overlay component itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:39:38 +00:00
Marco 8c5e284b6c Add price-range toggle filters to the shop overview
Scoped down from the original "category checkboxes + price slider"
sidebar proposal — Products have no category taxonomy today (only
Posts do), so a category filter isn't buildable against real data
yet. Price buckets are computed from the active catalog's actual
min/max price (not fixed round-number thresholds), so they stay
sensible regardless of what's actually being sold. Same URL-search-
param, plain-Link toggle-chip pattern as the blog/order-list filters.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:37:25 +00:00
Marco 72c7b0454a Add category toggle-button filters to the blog overview
URL-search-param-driven (?categories=A,B), same shareable/bookmarkable
approach as the order-list filters — plain server-rendered toggle
Links, no client component needed since /blog already fetches every
published post in one request (limit 100, no pagination).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:33:20 +00:00
Marco 11aa9689c1 Add wishlist feature (frontend), gated by CompanySettings.wishlistEnabled
New: useWishlist.ts (fetch+optimistic-toggle hook, server-backed since
a wishlist needs a logged-in customer, unlike the guest-friendly cart),
WishlistButton.tsx (heart toggle, login-redirects on 401), Navbar's
wishlist icon+badge (hidden below sm: — Account+Cart are the only
always-visible icons on true mobile, a 3rd icon there risks the same
computed nav-overflow class of bug documented in the figma-to-nextjs
skill), and /konto/merkliste (list page, 404s if the feature gets
disabled after a customer already has rows).

Product gained numericId (the raw Payload id) alongside its existing
slug id — WishlistItems.product is a real numeric relationship field,
unlike cart/checkout's slug-keyed "commerce id".

Backend counterpart (WishlistItems collection, CompanySettings toggle,
migration) already deployed separately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:28:30 +00:00
Marco 1d91a3f21c Replace native <select> filters with a fully custom-styled dropdown
A native <select>'s open options popup is rendered by the browser/OS
and can't be styled at all — wrong font size, wrong colors, no brand
styling whatsoever, reported as looking completely off-brand. New
CustomSelect.tsx renders both the trigger and the options list as
plain styled HTML (button + role="listbox" panel, keyboard nav,
click-outside-to-close) instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:13:51 +00:00
Marco 475ee3fa16 Replace order-filter chip wall with 3 compact selects; subgrid-align trust badges
OrderFilters.tsx: filter chips (7 status + 5 payment-status + N year
options as pills) read as cluttered and ate a lot of vertical space.
Replaced with 3 native <select>s in a row (a small Client Component
just for the onChange→router.push navigation) — same URL-search-param
filtering underneath, just a much more compact control.

TrustRow.tsx: replaced the hardcoded min-h-[3.2rem] title reservation
(a guess, and it visibly over-reserved space for single-line titles)
with a real CSS Grid + `grid-template-rows: subgrid` — every badge's
icon+title box shares the same row-track height (auto-sized to
whichever title actually needs 2 lines), so descriptions align
without any hardcoded value. The two structurally different layouts
(icon-above-title below lg vs. icon-beside-a-title/description-stack
at lg) now render as two separate blocks instead of fighting one
shared shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:11:12 +00:00
Marco c8cf6ae10d Fix trust-badge title/description misalignment, cap order-confirmation card width at tablet
TrustRow.tsx: reserve 2 lines of height for the title below lg: so the
description starts at the same y-position regardless of whether that
badge's own title happened to wrap to 1 or 2 lines in its narrow
stacked column.

BestellbestaetigungContent.tsx: same fix category as NewsletterModal's
dialog width cap — max-w-[56rem] doesn't constrain anything below
896px, so the order-details card stretched full width through the
640-1023px tablet range. Added max-w-[36rem] there, widening to the
real cap only once lg:'s sidebar split needs the room.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:05:40 +00:00
Marco 2f8c184dc6 Add status/paymentStatus/year filters to the order overview
Server-side, URL-search-param-driven filtering (?status=&paymentStatus=&year=)
so the page stays a Server Component — each filter chip is a plain Link
to the same route with one param changed, shareable/bookmarkable/back-
button-safe for free. getCustomerOrders() gained an optional filters
param translated into additional Payload where[] clauses; a new
getCustomerOrderYears() derives the year filter's option list from the
customer's actual orders instead of a hardcoded range. paymentStatus's
"Offen" filter option mirrors PaymentStatusBadge's own grouping
(pending + not_applicable as one option, not two).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:03:30 +00:00
Marco e7d5dd8b1e Revert Bestellnummer/Datum centering — back to left-aligned columns 1-2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:58:51 +00:00
Marco 79d3f836a2 Fix order-list card: explicitly pin every cell's row+column
Root cause of the "still left-packed" report: mixing `order` with only
some items explicitly positioned (col-start on Bestellnummer/Artikel)
is a footgun — Grid's auto-placement cursor for the un-pinned items
(Datum, Status, Zahlungsstatus, Gesamtbetrag) starts scanning from
column 1 again regardless of what's already explicitly placed
elsewhere, so Datum landed back in column 1 instead of column 3.
Every cell now gets an explicit row-start+col-start, removing the
ambiguity entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:55:24 +00:00
Marco 113d25eef9 Order list: center Bestellnummer/Datum row + center footer links on mobile
Bestellnummer now forces sm:col-start-2 so the top row's 2 items
(only spanning 2 of 4 tracks) center within the row instead of
packing flush left with all the leftover space on the right.

The Profil/Weiter einkaufen/Abmelden links row now centers itself
(self-center) below sm, matching the rest of the page's alignment
from sm up — shrink-to-fit width preserved, doesn't stretch full width.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:51:22 +00:00
Marco 785c1e6eba Center the cart summary trust badges row between sm and lg
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:49:21 +00:00
Marco 9024112d88 Order-list card: move breakpoint from 500px to sm (640px)
Consistency with the site's structural sm: breakpoint elsewhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:48:07 +00:00
Marco 7ce8d02afe Cart summary trust badges: don't stretch to full card width at tablet
Was sm:flex-1, growing each badge to evenly fill the card's full
width. Now sm:w-auto with no flex-grow, so each badge only takes its
own content width; sm:flex-wrap added as a safety net in case all 3
don't fit on one line at exactly 640px.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:46:57 +00:00
Marco 11c8d0b7b6 TrustRow: side-by-side row below lg with icon above text, no wrap
Cart summary trust badges: side by side between sm and lg

TrustRow.tsx: rebuilt as a single continuous below-lg tier instead of
mobile-nebeneinander/tablet-stacked — icon above text (shrinking each
badge to its text column's width) plus dropping whitespace-nowrap on
title/description below lg (wrapping within their own narrow column
instead of forcing overflow) means a full row of 3 badges now fits
without needing to wrap or stack, avoiding the previously-reverted
"odd item alone on its own line" issue a flex-wrap attempt hit.

CartContent.tsx: the sidebar's trust-badge list (title only) now goes
side by side between sm (640px) and lg (1024px) instead of always
stacked — that sidebar is full page width through that whole range,
plenty of room for 3 short titles in a row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:44:30 +00:00
Marco 6b8ba44d46 Shop overview: 2 products per row through tablet, 4 only from lg
Was 4-up from sm (640px) — too narrow a card through the 640-1023px
tablet range. True mobile (below sm) unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:36:57 +00:00
Marco ea7f4a4b3b Blog secondary post cards: image above text through tablet width
Was sm:grid-cols-5 — with the 2-up sm:grid-cols-2 outer grid, each
card's own width through 640-1023px was too narrow for image+text
side by side. Now stacks through lg (1024px), row layout only once
each card has real width. Featured post untouched (already fine).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:32:54 +00:00
Marco 6262a8ab01 Tools cards: icon above text below lg, left-aligned above mobile
Newsletter: left-align copy above mobile, fix missing vertical padding

Tools.tsx: icon+text now stacks below lg (1024px) instead of always
being a row — a 3-up grid from sm (640px) left each card too narrow
for side-by-side icon+text through 640-1023px. Centered on true
mobile, left-aligned from sm up.

Newsletter.tsx: same left-alignment treatment (centered only on true
mobile, left-aligned from sm up). Also removed the outer card's
sm:py-0 — it relied entirely on items-center centering against the
copy column's height for top/bottom breathing room, which broke down
whenever the form column (input/checkbox/privacy note) was as tall or
taller, leaving the input and "Keine Werbung" note flush against the
card edges. py-8 now applies at every breakpoint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:31:15 +00:00
Marco 7baf8f9e24 Keep the newsletter icon above the text through the whole tablet range
Icon+text switched to row layout at sm (640px), but the outer card
already puts this copy column next to the form from that same
breakpoint — squeezed the text through 640-1023px. Row layout for
icon+text now only kicks in at lg, where there's actually room.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:27:26 +00:00
Marco a036c89291 Fix Product Spotlight looking too wide on tablet
sm:flex-row (640px) put the 380px image side-by-side with text well
before there was comfortable room for it — tablet widths (768-1023px)
read as cramped. Now stays stacked like mobile through lg (1024px),
with a max-w-[32rem] cap between sm and lg so the stacked card reads
as centered/narrower instead of stretching to fill the tablet
viewport (true mobile stays unconstrained, the viewport is already
narrow there).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 21:25:46 +00:00
Marco 32f4068272 Slow down the mobile nav drawer's close animation
The shared ease-out curve (fast start, slow finish) worked well for
the open reveal but made the close shrink almost instantly then
linger on a barely-visible sliver — read as abrupt. Gave exit its own
longer, easeInOut transition instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 19:59:36 +00:00
Marco 275d5abd95 Close the mobile nav drawer when clicking the navbar itself
Added an onClick on <header> that closes the drawer whenever it's
open — the panel is a sibling, not a descendant, so this never fires
for clicks inside the open drawer, only the bar itself. The
hamburger's own click now stops propagation so toggling the drawer
open doesn't get immediately undone by this same handler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 19:57:14 +00:00
Marco 0f50ab334c Move Artikel to the front of the 4-column row
From 500px up, the second row is now Artikel/Status/Zahlungsstatus/
Gesamtbetrag (was Status/Zahlungsstatus/Artikel/Gesamtbetrag). Done via
min-[500px]:order-* so the below-500px pairing (badges together,
quantities together) stays on plain DOM order, untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 19:55:13 +00:00
Marco 54725f7c5f Give Bestellnummer/Datum their own row, group the other 4 fields below
From 500px up: 4 columns, with Status forced to col-start-1 so
Status/Zahlungsstatus/Artikel/Gesamtbetrag land together in one row
below Bestellnummer/Datum, instead of Grid filling the empty cells
next to Bestellnummer/Datum first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 19:52:48 +00:00
Marco 7c9bfc3897 Keep the order-list card at 2 columns always, drop the 4-column breakpoint
With the badges-together/quantities-together field order, 4 columns at
wider viewports put Status/Zahlungsstatus in the same row as
Bestellnummer/Datum instead of their own row. Plain 2 columns at every
width keeps the intended pairing regardless of available space.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 19:51:22 +00:00
Marco 014f1edcdc Reorder order-list card fields: badges together, quantities together
Status/Zahlungsstatus now land in one row (calmer than being split
diagonally across two rows), and Artikel/Gesamtbetrag pair up as the
two "quantity" fields. Also drops the now-unnecessary col-start-1 on
Artikel — Bestellnummer/Datum/Status/Zahlungsstatus already fill all
4 tracks of row 1 exactly with this order, so Grid wraps row 2 on
its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 19:48:29 +00:00
Marco cca555c0f4 Bump @einfach-produktiv/invoicing to 0.2.9, fixing /cart crash
0.2.8's fileURLToPath fix (for a Payload-backend-only Turbopack bug)
crashed every client bundle that transitively imports fonts.ts —
including this frontend's own /cart page, via CartContent.tsx's
computeTaxBreakdown import pulling in the invoicing barrel file.
0.2.9 branches on typeof window to keep both environments working.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 18:23:52 +00:00
Marco c6fe65ad99 Fix cart quantities doubling on every logout/login and a crash on /cart
Root cause 1 (count doubling): LogoutButton never cleared the local
cart, and mergeServerCartIntoLocal() (called on login) adds server
quantities into the existing local ones rather than replacing — since
CartSync mirrors local to the server continuously, local and server
already held the same quantities at logout time, so every login added
them together, doubling the count each cycle. Fix: clear the local
cart on logout, so the next login's merge starts from empty (or only
genuine guest-session additions) instead of re-adding already-synced
quantities.

Root cause 2 (page couldn't load): readCart()/getCart() only guarded
against JSON.parse syntax errors, not against the parsed value being a
non-array — once localStorage held a corrupted (inflated/malformed)
value, CartContent.tsx's cart.map() threw uncaught during render.
Fix: validate Array.isArray() after parsing, falling back to [].

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:13:54 +00:00
Marco 73b4b06eae Keep Bestellnummer/Datum side by side below 500px too
Reverts the col-span-2 full-width-row experiment for very narrow
phones — side by side reads better even with the occasional wrap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:59:20 +00:00
Marco 3704acab41 Document the order-list mobile grid iteration in the README
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:58:21 +00:00
Marco 53c452583d Move the order-list 2-to-4-column breakpoint from 400px to 500px
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:56:33 +00:00
Marco 80843cc08b Give Bestellnummer/Datum full-width rows on very narrow phones
Below 400px each now spans both columns of the 2-column layout (own
row), so the long order number doesn't wrap; from 400px up they drop
back to col-span-1 side by side in the 4-column layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:54:58 +00:00
Marco c6051442df Fix order-list mobile layout: 2 columns under 400px, badges no longer stretch
The forced 4-column layout overflowed on narrow phones — badge labels
like "In Bearbeitung" and the order number wrapped/overlapped into
neighboring columns. Drop to 2 columns below 400px (the field order
already tiles cleanly into 3 rows of 2). Also stop OrderStatusBadge/
PaymentStatusBadge from stretching to their flex-col parent's full
width — self-start keeps the badge background only as wide as its label.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:53:41 +00:00
Marco 1fe1a260e4 Keep the order-list card at 4 columns on all screen sizes
Drop the responsive grid-cols-2/4 switch entirely — stay in the
4-column layout even on the narrowest mobile widths, with a tighter
column gap below sm to help it fit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:49:16 +00:00
Marco 5d588c55b4 Push the order-list card's 4-column breakpoint out to lg
md (768px) still counts as mobile/tablet range — keep the 2-column
layout through that whole range and only switch to 4 columns at real
desktop widths (lg, 1024px).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:47:50 +00:00
Marco 44f98a3d64 Delay the order-list card's grid breakpoint from sm to md
The card sits inside a max-w-[56rem] container with padding, so at
the sm breakpoint (640px viewport) the card itself is still too
narrow for 4 columns — the wrap to 4 columns was happening too early.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:45:24 +00:00
Marco 8ddde89143 Force Artikel to start a new grid row on the order list
sm:grid-cols-4 left 2 empty cells in row 1 after Bestellnummer/Datum
became col-span-1, so Grid auto-placement flowed Artikel into the
same row instead of wrapping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:40:52 +00:00
Marco 84e21589c3 Fix order-list grid: Datum should start in column 2, not 3 2026-07-30 13:34:34 +00:00
Marco 9502cf81b9 Unify order-list card into a single grid for consistent column alignment
One grid for the whole card (Bestellnummer/Datum spanning 2 of 4 tracks
from sm: up, everything else spanning 1) instead of two separate flex/grid
groups. Plain flex-wrap sized every field to its own content width, so a
field could start at a different x position from one order to the next
depending on how wide that particular badge label happened to be — a
grid's column tracks are fixed by the container width, identical on every
card regardless of content, which is what actually guarantees consistent
alignment across different orders.
2026-07-30 13:29:19 +00:00
Marco 18f5156fd8 Attach the invoice PDF to the payment-method-switched email, brand-tone copy
buildInvoiceAttachment() extracted out of sendOrderConfirmationEmail() so
sendPaymentSwitchedEmail() can reuse the same invoice-PDF generation
instead of duplicating it — the invoiceNumber stays the same, but
paymentMethodTitle now reflects the confirmed instrument, which flips
the PDF's own paid/unpaid display, so re-attaching a fresh copy matters
here even though nothing else about the order changed.

Default fallback copy replaced with brand-toned wording matching the
existing order-confirmation fallback's voice, instead of a flat
system-notice tone.
2026-07-30 11:14:18 +00:00
Marco f0b2d21989 Send a dedicated email for a switched Überweisung payment, not the full order-confirmation
New sendPaymentSwitchedEmail() — sent instead of sendOrderConfirmationEmail()
when confirmPaymentEmail.ts sees order.paymentSwitchedAt set. Resending the
full order-confirmation (with its invoice re-attached) after a payment-method
switch read like a brand-new purchase; this is a short, dedicated "Zahlung
erhalten" confirmation instead, matching the order-status-email shape.

Also bumps @einfach-produktiv/invoicing to 0.2.8, fixing a font-path bug
that broke every invoice PDF render inside the Payload backend specifically.
2026-07-30 11:04:27 +00:00
Marco 2e537bc78d Restrict Rücksendung anfragen to delivered orders, fix Impressum email link
- customerOrderAction() only offers "Rücksendung anfragen" once an order
  is delivered, not already at shipped — a return before the package
  arrived doesn't make sense yet. UI-only change (a stricter subset of
  what the backend's CUSTOMER_ALLOWED_TRANSITIONS already permits).
- AnbieterAngaben.tsx's seller email is now a real mailto: link — it was
  plain text, the only non-clickable email on the site.
2026-07-30 10:39:08 +00:00
Marco 4adb844f8d Move Zahlungsart-ändern button next to Bestellung-stornieren 2026-07-30 10:12:11 +00:00
Marco beea592706 Reflect Orders.paymentStatus's new meaning for Überweisung orders
- switch-to-stripe eligibility gained paymentStatus !== "paid" — an
  order an admin already marked paid by hand must never also be
  switchable to Stripe.
- PaymentStatusBadge: "Offen" now renders in the same red/subtle
  style as a failed status (was neutral grey) — worth visually flagging,
  now that it's a real tracked state rather than a permanent placeholder.
2026-07-30 10:08:37 +00:00
Marco 6448c8736a Fix broken email-preview rendering, add payment status to order list, second sample state
- LiveEmailPreviewClient.tsx rendered the email HTML (a full <body>...
  fragment) via dangerouslySetInnerHTML into a plain div, nesting it
  inside the page's own already-existing <body> — invalid HTML the
  browser silently mangled. Switched to an <iframe srcDoc>, giving the
  email its own real document context, exactly like an actual email
  client would render it.
- Added SAMPLE_ORDER_MANUAL + a toggle in the preview so both
  order-confirmation states (paid vs. Vorkasse/Überweisung, incl. the new
  "switch to Kreditkarte/PayPal" mention) are actually visible in the
  admin's Live Preview — SAMPLE_ORDER alone always had isManualPayment:
  false, so the Vorkasse branch was never previewable at all before this.
- /konto/bestellungen (the order list, not just the single-order detail
  page) now also shows the payment status badge next to the existing
  fulfillment status one — CustomerOrder was missing paymentStatus
  entirely.
2026-07-30 09:56:06 +00:00
Marco 0e995884a7 Allow switching an unpaid Überweisung order to Stripe payment
New "Zahlungsart ändern" button on the account order detail page,
shown for a still-'received', still-manual (Überweisung) order when an
active Stripe payment method exists. Reuses PaymentStep (the same Stripe
Payment Element checkout uses) and /checkout/verarbeitung's polling logic
(both now take a returnContext prop/param to land back on the order page
instead of clearing the cart and redirecting to /bestellbestaetigung).

Also:
- Payment status badge (Offen/Bezahlt/...) next to the existing Zahlungsart
  display on the order detail page.
- A one-line mention of the switch option in the Vorkasse unpaid notice
  in the order-confirmation email, shown only when a Stripe option is
  actually active (hasOnlinePaymentOption).
- CustomerOrderDetail gained paymentProvider (was missing from the type
  entirely, even though the field already existed on the order).

Backend counterpart: docker/payload's switchPaymentToStripeEndpoint.
2026-07-30 09:48:44 +00:00
Marco 8c843c0ac1 Profile: Rechnungsadresse is always a street address, no Packstation toggle
Matches /checkout's own billing card exactly (an invoice needs a real
postal address) — the profile form previously offered a Lieferart/
Packstation choice for what's actually always used as the billing
address, inconsistent now that a separate "Lieferadresse" section exists.
2026-07-30 08:48:04 +00:00
Marco e3352d7e32 Blog categories (hasMany), shipping-address contact fields, product SKU on invoices
- Posts.categories is now hasMany — blog list/detail/live-preview render
  a comma-joined list instead of a single category.
- Checkout's "Abweichende Lieferadresse" gains optional Firma + Kontakt-
  E-Mail/Telefon fields (handed to the shipping carrier, not used for
  customer communication).
- Customer profile can now store its own shipping address (mirroring
  Customers.ts's new "Lieferadresse" tab), prefilling the checkout
  override instead of always starting blank.
- Product-level optional SKU (previously only on variants) snapshots onto
  each order item and shows up on invoices (visual + EN16931 XML),
  the confirmation email, and the account order detail page.
2026-07-30 08:41:39 +00:00
Marco 1467750db6 Hide the free-shipping progress banner when the cart has no shippable items
Reuses FreeShippingBanner's existing threshold===null early return —
a "€X bis kostenlosem Versand" nudge makes no sense for a cart that was
never going to be charged shipping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 23:15:32 +00:00
Marco c83bbfbc35 Filter draft/scheduled posts out of public blog fetches
getBlogPosts()/getPostBySlug() now only return status='published' posts
for normal requests — draftMode's live preview passes {draft:true} to
bypass it, same as before. Backend-side scheduling in the payload repo
(status/scheduledPublishAt + a per-minute autopublish job).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 23:10:07 +00:00
Marco c852752006 Cap Newsletter card at 1280px, show an explicit "Keine Versandkosten" hint, generalize the /versand exemption note
- Newsletter.tsx: max-w-[1280px] mx-auto, matching /lebensuhr's own
  newsletter-style section and Footer.tsx's existing 1280px convention —
  previously unbounded and stretched edge-to-edge on wide viewports.
- Product pages: noShippingCost now shows "Keine Versandkosten" instead
  of just omitting "zzgl. Versand" silently.
- /versand's exemption note no longer implies only digital products can
  be exempt — noShippingCost is a general per-product flag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 23:01:17 +00:00
Marco a3f843ad15 Honor Products.noShippingCost across cart, checkout, and product pages
- api/checkout/route.ts: authoritative shipping charge is 0 whenever
  every cart line opts out via noShippingCost, regardless of the
  free-shipping threshold.
- Cart/checkout order summaries: the whole "Versand" line (cost, free-
  shipping note, delivery time) is hidden entirely rather than showing
  "Kostenlos" — that's a different state from hitting the threshold.
- ProductSpotlight/Pricing/TodoKartenHero: "zzgl. Versand" and delivery-
  time hints drop for an exempted product's own page.
- /versand + its shared modal: one clarifying sentence that digital
  products are exempt.
- Widerrufsformular link: opens inline in a new tab (no forced download),
  arrow icon changed from a download glyph to a plain right arrow to
  match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 22:48:33 +00:00
Marco bffc7d39a2 Widerrufsformular link opens inline in a new tab instead of forcing a download
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 22:35:37 +00:00
Marco 70abe20250 Generate Muster-Widerrufsformular PDF live from company-settings instead of a static upload
The "An:" address used to be baked into a hand-crafted PDF and silently
went stale whenever an admin updated the Impressum's Anbieterdaten
without also re-exporting/re-uploading the file by hand — exactly what
happened 2026-07-29. New /api/muster-widerrufsformular route renders it
on-the-fly via @react-pdf/renderer using the same getCompanySettings()
data as AnbieterAngaben.tsx's Impressum block, so the two can never drift
apart again. Disables react-pdf's default hyphenation, which otherwise
split the seller's email address mid-word to fit the line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 22:30:51 +00:00
Marco 22fef4fe49 Switch order/verification email From to sellerEmail's domain, honor emailFromName/emailFromAddress override
Mirrors the backend's sellerInfo.ts buildFromHeader() change — SMTP
account moved to admin@einfach-produktiv.com, so From can finally point
at sellerEmail itself instead of the fixed admin@mk360.de placeholder.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 22:22:23 +00:00
Marco fb1bedcacd Fix invisible Stripe pay button, add brand favicon/OG image, navbar 3x3-System CTA, blog hero crop
- PaymentStep: bg-brand-primary was never a real Tailwind class, leaving
  the "Jetzt bezahlen" button unstyled/invisible; switched to bg-brand
  with dark text, matching every other CTA on the site.
- New app/icon.svg + app/apple-icon.png + app/favicon.ico: brand-yellow
  rounded square with a bold italic "e", replacing the Next.js default.
- New public/og-image.png (logo on the site's cream background) wired in
  as the SEO fallback default OG image/description/title in payload.ts.
- Navbar CTA: "Klarheitskompass" -> "Mein 3x3-System", now links to
  /3x3-system (page not built yet).
- Blog overview hero image anchored to the top instead of center-cropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 21:48:22 +00:00
Marco 27d383b559 Blog overview hero: switch to hero.png (hero.jpg was removed)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 21:16:54 +00:00
Marco 4524005f0e Newsletter-confirmed letter: add compass emoji to Klarheitskompass heading
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 21:13:10 +00:00
Marco 14b47fd049 Newsletter-confirmed letter: "wöchentlich" wording, split invitation into two paragraphs
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 21:12:06 +00:00
Marco 47789e94db Tweak newsletter-confirmed letter: note the separate Klarheitskompass email, trim copy
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 21:09:25 +00:00
Marco a6e1734799 Replace newsletter-confirmed generic copy with Björn's personal welcome letter
Includes the Klarheitskompass gift callout and Sonntags-Impulse framing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 21:02:06 +00:00
Marco 0a467aed59 Tighten newsletter modal's input-to-submit-button gap
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 20:52:42 +00:00
Marco 14094ab1d4 Update newsletter/brand copy, hide VAT section without a vatId, relabel Lebensuhr breadcrumb
- Newsletter panel + modal: new title/description copy, weekly cadence wording
- Brand mentions ("einfach-produktiv") switched to "einfach produktiv."
- Impressum: Umsatzsteuer section (heading + TOC entry) only renders when vatId is set
- /lebensuhr breadcrumb eyebrow now reads "Lebensuhr" instead of "7-Tage-Challenge"

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 20:49:46 +00:00
Marco 96fddf4cd9 ueberarbeitung mit bjoern 2026-07-29 20:11:55 +00:00
Marco 3fc98b8480 Correct README/skill: TrustRow's flex-wrap was reverted, not kept
Documents the third round: flex-wrap looked disorganized with exactly
3 badges (the lone wrapped item didn't align under either item above
it), reverted back to strict single-column stacking the same day.
2026-07-29 12:39:39 +00:00
Marco 5f219c23f9 Revert TrustRow from flex-wrap back to single-column stacking
The flex-wrap experiment (2 badges per row + 1 wrapped centered below)
looked disorganized with exactly 3 badges — the wrapped single item
doesn't align under either item in the row above it, reported as
"durcheinander". Reverts to strict single-column stacking below lg:
(the inner-shrink-to-fit-group + outer center/left-align pattern),
which stays visually clean regardless of how many badges exist.
2026-07-29 12:38:44 +00:00
Marco 908f46c962 Document TrustRow's flex-wrap rewrite and the modal sticky-button fix
Covers the two follow-up rounds after the Footer/TrustRow lg: revert:
the items-center misalignment bug and its flex-wrap resolution, plus
NewsletterModal's absolute-to-sticky close button fix. Cross-references
the figma-to-nextjs skill's new Gotchas 22-23.
2026-07-29 12:36:22 +00:00
Marco d389790c57 TrustRow: flex-wrap instead of a hard lg: breakpoint; sticky modal close button
TrustRow now uses flex-wrap + justify-center so badges flow as many
per row as actually fit at the current width, wrapping (and
auto-centering, a native flexbox behavior for the last wrapped line)
instead of needing a breakpoint or risking overflow. Replaces the
earlier lg:-only structural exception entirely.

NewsletterModal's close button was absolutely positioned inside the
dialog's own scrolling container, so it scrolled away with the
content. Switched to sticky (top-6, ml-auto for horizzontal position,
-mb-6 to cancel its own height so it doesn't push content down) so it
stays pinned to the top-right corner while scrolling.
2026-07-29 12:35:12 +00:00
Marco 3aa8720daf Fix TrustRow badges misaligning with each other when centered
items-center directly on the badge container centered each badge
independently within the container's width — badges with different
title/description lengths ended up with different left edges instead
of lining up (confirmed via our own screenshot: icons at different x
positions below 640px). Wraps all badges in one inner shrink-to-fit
group (always items-start internally, so every badge shares the same
left edge) and centers/left-aligns that single group as a unit from
the outer wrapper instead.
2026-07-29 12:25:49 +00:00
Marco 077986affb Revert "Center cart's CartTrustBadges list below 640px"
This reverts commit 5d61dce12b.
2026-07-29 12:22:38 +00:00
Marco 5d61dce12b Center cart's CartTrustBadges list below 640px
The previous TrustRow.tsx fix didn't touch this — the cart summary
sidebar renders its own inline trust-badge list, not the shared
TrustRow component. Each row was w-full unconditionally, so
items-center on the container was a no-op (a full-width flex child
centers to no visible effect). Now w-auto + justify-center below sm:
(icon+title as a compact centered unit), w-full + justify-start (+
flex-1 on the title) from sm: up, matching the tiered-alignment pattern
used elsewhere (Hero's social proof, TrustRow.tsx).
2026-07-29 12:17:54 +00:00
Marco 17679cc81e Only center TrustRow badges below 640px, left-align through Tablet
Centering the whole non-lg: range (previous fix) overcorrected — only
true Mobile should center (matches other centered content there); the
640-1023px Tablet band left-aligns instead, each badge's left edge
lining up via items-start (no per-badge centering, so nothing reads
crooked). Same tiered-alignment pattern as Hero's social-proof row.
2026-07-29 12:13:20 +00:00
Marco 19440b9bcf Document the Footer/TrustRow lg: regression and the Playwright verification method
Both components were wrongly reclassified as plain sm: renames during
the 640px migration; they'd been on lg: for a genuine fixed-content-
width reason, not the fluid-floor bug the migration targets. Also
documents that a real headless-browser check (Playwright, installed
into a scratch dir) is available in this environment after all —
caught both regressions via scrollWidth vs. innerWidth measurements
against the live site.
2026-07-29 12:11:23 +00:00
Marco 56fc8d970d Revert TrustRow's breakpoint from sm: to lg:, center stacked badges
Same fixed-content-doesn't-fit bug as Footer's legal-links row,
confirmed via live-site horizontal-overflow measurement: ~100px
overflow at 666px viewport, ~54px at 768px. Multiple whitespace-nowrap
title+description badges don't fit in one row below ~1024px regardless
of fluid scaling. Also centers stacked badges (was items-start below
sm:, now items-center through the whole non-lg: range) per feedback.
2026-07-29 12:09:44 +00:00
Marco 61afa0dcae Revert Footer's row breakpoint from sm: back to lg:
Confirmed via Playwright against the live site (666px viewport): this
row was causing a real horizontal page overflow (scrollWidth 746px vs
666px viewport) in the 640-1023px band, not just visual cramping —
logo + handle + 5 nowrap legal links (all shrink-0) don't fit in one
row below ~1024px regardless of fluid scaling. Today's earlier sm:
rename wrongly treated this as a simple structural-breakpoint case;
it's actually the same fixed-content-doesn't-fit category as
Cart/Checkout/SectionTOC, which correctly stayed at lg:.
2026-07-29 12:04:07 +00:00
Marco 724530b073 Cap NewsletterModal's width through Tablet instead of just stacking it
Stacking single-column at lg: (previous attempt) let the dialog stretch
to near-full-viewport-width at real Tablet widths, since max-w-[75rem]
barely constrains it there — the photo blew up huge and the text below
read as lost/disconnected. Caps the dialog at max-w-[36rem] through the
640-1023px band (only widening to 75rem once the lg: 2-column split
kicks in), so it reads as a compact modal at every width, not a
near-full-bleed stack.
2026-07-29 11:35:31 +00:00
Marco 5c43a49ba9 Revert "Break NewsletterModal at lg: instead of sm:"
This reverts commit e0299713f5.
2026-07-29 11:33:40 +00:00
Marco e0299713f5 Break NewsletterModal at lg: instead of sm:
The dialog is capped at max-w-[75rem] (1200px), so at real Tablet
viewports (640-1023px) it's essentially full-viewport-width — too
little room per column at sm: for the heading/form or the 3 feature
cards to read well. Stacked through the whole Tablet range now,
side by side again only from lg: up.
2026-07-29 11:32:09 +00:00
Marco a9c8344472 Add a soft edge fade to About's photo at Tablet too
The left gradient was gated to lg: (matching the -ml-48 overlap), but
without any overlap at Tablet the photo sits flush against the dark
text column with a hard vertical seam. Narrower fade (w-16) from sm:
up softens that seam; widens to w-72 at lg: to also cover the deeper
overlap once that kicks in.
2026-07-29 11:24:38 +00:00
Marco cbb423f8ca Center Hero heading/subheading below 640px too
CTA and social-proof were already centered on true Mobile, but the
heading/subheading above them stayed left-aligned — read inconsistent.
Centers the whole text block below sm:, left-aligned again from sm: up.
2026-07-29 11:13:48 +00:00
Marco dfb6eb5e23 Re-center Hero social proof below 640px, keep it left-aligned at Tablet
Centering it site-wide below lg: overcorrected — true Mobile (<640px)
should stay centered (matches the CTA there), only the 640-1023px
Tablet band needed the left-align fix from the previous commit.
2026-07-29 11:08:40 +00:00
Marco 5e1d88d13e Left-align Hero's social proof row below lg:
Centered avatars+text below lg: read as floating/disconnected from the
left-aligned heading/subheading/CTA above it. Left-aligned now to match
the rest of the column; still switches to a single centered row at lg:.
2026-07-29 11:05:59 +00:00
Marco c3a6c84d8a Give About's photo more room at Tablet, not the text column
The wider text share at sm: didn't actually help the inner quote/bio
row go inline (it stays stacked until lg: regardless) — it just took
space away from the photo, which read as too narrow/cramped on Tablet.
1:1.4 text:image ratio now applies from sm: up; only the -ml-48 overlap
itself stays gated to lg:, since that trick needs more absolute room.
2026-07-29 11:02:29 +00:00
Marco 932cf60c5c Fix About Tablet layout, center Hero CTA on Mobile
The sm:/lg: ratio collapse in About.tsx from the breakpoint migration
was wrong: the Tablet-specific wider text share wasn't just compensating
for the grid arriving too early, it's what the inner quote/bio row and
the photo's -ml-48 overlap actually need room for. Restores the
two-tier ratio (wider text, no overlap at sm:; real ratio + overlap
only at lg:), just anchored to sm: instead of the old md:.

Also centers the Hero CTA button below sm: (Mobile) per feedback.
2026-07-29 10:58:36 +00:00
Marco cc935420ec Move structural breakpoint to 640px, shorten Hero heading
Tablet layout (768-1023px) hit the md: grid switch exactly where the
fluid clamp() tokens were already at their floor, leaving no room to
shrink. Moves the fluid floor and structural breakpoint down together
to sm: (640px) so real tablets always get the fluid, desktop-like
structure; consolidates the ad-hoc md:+lg: patchwork in Hero/About/
Newsletter/Footer/Tools back onto one line, leaving documented lg:
exceptions where content genuinely doesn't fit yet. Also shortens the
Hero heading to a single sentence with no trailing period (the brand's
orange dot already renders one, animated).
2026-07-29 10:49:56 +00:00
Marco 94cba756c8 Remove trailing period from Werkzeuge section heading
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 09:58:32 +00:00
Marco b835e8622f Align newsletter modal copy with the homepage newsletter block
NewsletterModal.tsx advertised a different offer (the 7-Tage-Challenge)
than Newsletter.tsx's own description (7 Impulse) for the same signup
flow. Also rewrote both descriptions to drop the vague "und gewinne
Klarheit" buzzword formula and the word "Klarheit" repeated from the
heading directly above it.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:20:15 +00:00
Marco 1706da8598 Add schema.org structured data, Vorkasse email notice, newsletter duplicate detection
- Organization (site-wide), Product (/todo-cards), BlogPosting (every
  /blog/[slug]) JSON-LD via new app/lib/structuredData.ts — no new
  Payload fields needed, derived from existing data. Verified locally
  by curling each page and checking the rendered script tag.
- Order confirmation email gains the same "please transfer to this
  account, processed after payment received" notice the invoice PDF
  already had for Vorkasse orders — OrderConfirmationData's new
  isManualPayment flag is set explicitly by each caller (never derived
  from paymentMethodTitle, which already broke once this session after
  a payment-methods rename). CompanySettings gains bankName (existed on
  the backend, was missing from the frontend's type/usage).
- Newsletter signup now detects an already-subscribed email
  (verified empirically: Brevo's doubleOptinConfirmation endpoint gives
  identical 201 responses for new vs. already-confirmed contacts) via a
  GET /v3/contacts/{email} pre-check, and shows a distinct message
  instead of silently resending the confirmation mail. Success message
  text centralized in useNewsletterSignup.ts instead of duplicated
  across 4 forms.
- Bumped @einfach-produktiv/invoicing to the version with the
  unpaid-notice layout fix (full width, more top spacing — was
  squeezed into the narrow paid-badge column).

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 14:48:45 +00:00
98 changed files with 4768 additions and 661 deletions
+680 -11
View File
@@ -120,7 +120,7 @@ explains what does have access instead.
| `orders` | Persisted checkout orders, `/konto/bestellungen*` | `orderNumber`, `invoiceNumber`/`invoiceIssuedAt`, `correctionInvoiceNumber`/`correctionInvoiceIssuedAt` (see "Invoice PDFs" below), `status` (`received`/`processing`/`shipped`/`delivered`/`cancelled`/`return_requested`/`returned` — the first 4 maintained by hand in the admin, no carrier API; the rest see "Order cancellation & returns"), `returnReason` (captured from the customer on a return request), full address/items (each with a snapshotted `taxRatePercent`/`bundleContents`)/totals at order time. **Not public-read** — created only via `ORDER_SERVICE_SECRET`, read/updated by admin or the order's own customer |
| `customers` | Storefront accounts — register/login/order-history, a second `auth: true` collection separate from the Payload admin's own `users` login | `customerNumber`, `firstName`/`lastName`/`email`, one default address, `cart` (server-side mirror), `emailVerified` (non-blocking). **Not public-read** — see "Orders & customer accounts" below |
| `number-ranges` | Admin-configurable prefix + running counter for customer/order/invoice/correction-invoice numbers — one row per tenant | `customerPrefix`/`customerNext`/`customerPadding`, `orderPrefix`/`orderNext`/`orderPadding`, `invoicePrefix`/`invoiceNext`/`invoicePadding`, `correctionInvoicePrefix`/`correctionInvoiceNext`/`correctionInvoicePadding` (Stornorechnung/Gutschrift — its own gapless sequence, not the same counter as `invoice*`, see "Invoice PDFs" below). **Admin-only**, no frontend read at all — internal to the two `beforeChange` hooks that assign these numbers |
| `email-templates` | Editable subject/heading/body/footer for all 6 transactional emails this shop sends (see "Email templates & Live Preview" and "Status-change emails" below) | `type` (`order-confirmation`/`password-reset`/`order-shipped`/`order-cancelled`/`order-return-requested`/`order-returned`), `subject`, `heading`, `bodyText`, `footerText`. Public-read, has a Live Preview button |
| `email-templates` | Editable subject/heading/body/footer for all 9 transactional emails this shop sends (see "Email templates & Live Preview" and "Status-change emails" below) | `type` (`order-confirmation`/`password-reset`/`order-shipped`/`order-cancelled`/`order-return-requested`/`order-returned`/`order-tracking-added`/`order-tracking-corrected`/`order-delivered`), `subject`, `heading`, `bodyText`, `footerText`. Public-read, has a Live Preview button |
| `company-settings` | Structured business data for invoice PDFs *and* every email's legal footer (Anbieterkennzeichnung, see "Invoice PDFs" below) — one row per tenant, own **Company** admin group (not Commerce — this is business identity, not a storefront concern) | `sellerName`/`sellerStreet`/`sellerZip`/`sellerCity`/`sellerCountry`/`sellerEmail`, `vatId`, `taxRatePercent` (admin-editable, not hardcoded), `bankName`/`iban`/`bic` (`iban`/`bic` format-validated + uppercase-normalized; `bankName` stays free text — replaced a single free-text `bankDetails` field). **Not public-read** — admin or `ORDER_SERVICE_SECRET`. Has a Live Preview button — see "Company Settings & Live Preview" below |
All of the above except `company-settings`, `media`, `users`, `tenants`
@@ -374,7 +374,12 @@ exactly as before: no gateway involved, order goes straight to `received`.
(`app/lib/payments/confirmPaymentEmail.ts`, only when the response isn't
`alreadyProcessed: true` — a repeat webhook delivery must never resend
it), mirroring exactly what the checkout route already does inline for
a manual/Überweisung order.
a manual/Überweisung order. Product photos in that snapshot's
`items[].imageUrl` need no frontend change to work —
`ConfirmPaymentOrderSnapshot`/`OrderConfirmationItem` already typed the
field; the backend just wasn't populating it (fixed there, see its own
README — needed `depth: 2` so `item.product.image` resolves to a real
`Media` doc).
- **`/checkout/verarbeitung`** (`VerarbeitungContent.tsx`) is the
`return_url` target. Neither a client-side `confirmPayment()` success nor
landing back from a PayPal redirect is trusted as proof of payment on its
@@ -407,6 +412,68 @@ Stripe API call itself that's faked. That test-confirm route hard-404s
whenever `PAYMENT_TEST_MODE` isn't explicitly true, so it can never become
a reachable "mark any order paid" endpoint in production.
**Activating real Stripe payments — checklist.** Everything code-side is
already live (both `main` branches deployed); this is purely
provisioning. Nothing here is required to *test* the flow today —
`PAYMENT_TEST_MODE` already works end to end with zero Stripe account.
Already done, as of 2026-07-25:
- [x] `PAYMENT_WEBHOOK_SECRET` set in Coolify, matches the backend's copy
in `docker/.env` — needed even in test mode (the test-confirm route
sends it as a header the backend checks).
- [x] `payment-methods` rows configured: Kreditkarte/PayPal →
`provider: 'stripe'`; "Überweisung (Vorkasse)" → `provider: 'manual'`
(untouched by anything below); "Sofortüberweisung" prepared as
`provider: 'stripe'` but `active: false` (needs a logo before switching
on — see the backend's own README).
- [x] Database migration applied to production, confirmed live.
Still needed, in order:
1. **Create a Stripe account** (free). Stay in **test mode** first (toggle
top of the Stripe dashboard) — nothing below moves real money until
step 5.
2. **Copy the test API keys***Entwicklerbereich → API-Schlüssel*:
`sk_test_...` and `pk_test_...`.
3. **Create a webhook endpoint** — *Entwicklerbereich → Webhooks → Endpoint
hinzufügen*, URL `https://einfach-produktiv.mk360.de/api/webhooks/stripe`,
events: at minimum `payment_intent.succeeded` and
`payment_intent.payment_failed`. Copy the signing secret, `whsec_...`.
4. **Set these three in Coolify** (`einfach-produktiv` app → Environment
Variables):
| Variable | Value |
|---|---|
| `STRIPE_SECRET_KEY` | `sk_test_...` |
| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | `pk_test_...` |
| `STRIPE_WEBHOOK_SECRET` | `whsec_...` |
The moment `STRIPE_SECRET_KEY` is set, `PAYMENT_TEST_MODE` switches off
automatically (see above) — the real Payment Element replaces the mock
buttons. Still 100% safe: Stripe's own test mode only accepts test card
numbers (e.g. `4242 4242 4242 4242`), no real charge is possible.
5. **Set the same `sk_test_...` in the backend** too —
`/home/marco/dev/docker/.env`'s `STRIPE_SECRET_KEY` (used only for
`stripeRefund.ts`, Storno/Gutschrift refunds) — then
`cd ~/dev/docker && docker compose build payload && docker compose up -d payload`
to pick it up.
6. **Redeploy the frontend** so it picks up the new Coolify env vars —
either wait for the next git push (Coolify redeploys on push) or
trigger one directly: `curl -X POST https://coolify.mk360.de/deploy/einfach-produktiv`.
7. **Test end to end**: place a real order with a Stripe test card,
confirm the order flips to `received`, invoice/confirmation email
arrive, product images show, `Orders.paymentStatus` reads `paid`. Try a
declined test card too (e.g. `4000 0000 0000 0002`) and confirm the
order shows "Zahlung fehlgeschlagen" in the admin, not in the
customer's own order history.
8. **Trigger a Storno on a paid test order** in the admin, confirm the
refund job actually calls Stripe (check the PaymentIntent in the Stripe
dashboard) and `Orders.refundStatus` updates.
9. **Go live**: only once ready for real charges — verify the Stripe
account for live payments (business details), switch the dashboard to
**live mode**, repeat steps 26 with the live-mode keys (`sk_live_...`/
`pk_live_...`, a *new* webhook endpoint registered in live mode → new
`whsec_...`) — these replace the test values in both Coolify and
`docker/.env`, not additional variables.
### VAT display
Every price shown storefront-wide says "inkl. X% MwSt." with the *actual*
@@ -444,6 +511,24 @@ catalog, since an order only ever snapshots a numeric product id).
touched spots and why some read a live setting and others a persisted
per-order snapshot.
**Vorkasse instruction in the confirmation email (2026-07-25)** — the
invoice PDF already showed this (see `@einfach-produktiv/invoicing`'s own
`unpaidNoticeText`), but a customer often only glances at the email body
itself, not the attached PDF. `OrderConfirmationData` gained a required
`isManualPayment: boolean` field — **explicitly set by each caller**
(checkout route's manual branch: `true`; the Stripe webhook path:
always `false`, since only a *paid* Stripe order ever reaches that send at
all), deliberately **not** derived from `paymentMethodTitle` inside
`emailTemplates.ts` itself — that string ("Online-Zahlung", "Kreditkarte",
"Überweisung (Vorkasse)", ...) is exactly the kind of thing a
payment-methods rename already broke once this session (see
`isPaidImmediately()` in the invoicing package). When `isManualPayment` is
true, `vorkasseNotice()` renders a full-width block (own row, `margin-top:
20px` — not squeezed into the Gesamtsumme table) naming the bank details
(`CompanySettings.bankName`/`iban`/`bic`, now also exposed on the
frontend's own `CompanySettings` type — previously only `iban`/`bic` were)
and the "processed within 12 business days after payment received" note.
### Checkout state persistence
`app/lib/checkoutDraft.ts``localStorage` under `ep_checkout_draft`,
@@ -1081,12 +1166,44 @@ unsynced.
heading, thin brand divider). No query params to read — Brevo's
redirect carries nothing this page needs, unlike `/checkout/verarbeitung`
which polls actual payment status.
- **Already-subscribed detection (2026-07-25)** — `doubleOptinConfirmation`
itself gives no way to tell a brand-new signup apart from an
already-confirmed contact re-submitting the form (verified directly:
calling it twice for the same confirmed contact returns the identical
`201` both times, just silently resends the confirmation mail). So
`upsertNewsletterContact()` checks first via `GET /v3/contacts/{email}`
— a contact's `listIds` on Brevo is only populated once double opt-in
actually confirms, never for a merely-requested one, so its presence is
a reliable signal. If already subscribed: skips the resend entirely and
returns `{ ok: true, alreadySubscribed: true }` instead. The check fails
open (any error → proceed to the normal signup flow) — it's a UX
nicety, never a reason to block a real signup. **Routed through the
existing error state, not a success variant** — per explicit feedback,
swapping the whole form out for a bare message (the real-success
treatment) felt wrong for "you're already signed up, nothing to do"; the
form stays visible with a small red note below it instead, exactly like
every other inline validation error (`useNewsletterSignup.ts` sets
`status: "error"`, `error: "Diese E-Mail-Adresse ist schon für unseren
Newsletter angemeldet."` — no new UI needed in any of the 4 forms, they
already render `{status === "error" && <p className="text-red-600
...">{error}</p>}`).
- **`app/lib/useNewsletterSignup.ts`** — the shared email/consent/submit
state + on-blur validation + refocus-on-invalid-submit behind all four
forms (same "state of the art, simple" input-quality bar as checkout's
own fields). Each form keeps its own markup/visual style (`Newsletter`'s
panel layout, `/challenge`'s hardcoded-hex-color palette, etc.) — only
the logic is shared, not a one-size-fits-all component.
the logic is shared, not a one-size-fits-all component. The real-success
message text also now lives here (`successMessage`) instead of
hardcoded 4 times per form.
- **A misconfigured `BREVO_LIST_ID` in Coolify (`2` instead of `5`) broke
every newsletter signup in production for a stretch of time** —
discovered and fixed 2026-07-25 while testing the already-subscribed
feature above. The generic customer-facing error message ("Anmeldung ist
fehlgeschlagen...") gave no hint why; `upsertNewsletterContact()`'s real
`reason` was silently discarded by `/api/newsletter/subscribe/route.ts`
before this, now `console.error`'d server-side (message still stays
generic to the customer — never leak Brevo's internal error text, just
no longer *undiagnosable*).
- **`app/lib/email.ts`** — `isValidEmail()`/`validateEmailFormat()`,
the single plain-email-format check shared by every newsletter form
*and* checkout's own email field (previously duplicated between
@@ -1110,6 +1227,21 @@ unsynced.
also needs `BREVO_DOUBLE_OPTIN_TEMPLATE_ID` (no safe default — every
signup silently no-ops without it) and optionally
`BREVO_DOI_REDIRECT_URL`.
- **The already-subscribed error now clears on the next interaction**
(2026-07-25), matching standard form-validation behavior instead of
sitting there until the next submit — `useNewsletterSignup.ts`'s
`handleEmailChange`/new `handleConsentChange` both call a shared
`clearSubmitError()` that resets `status`/`error` back to idle. This
replaced the hook's previously-exposed raw `setConsent` with
`handleConsentChange` in its return value — all 4 consuming forms
updated to match.
- **`NewsletterModal.tsx`'s photo no longer resizes when the error
appears** (2026-07-25) — the modal's left photo stretches
(`items-stretch`, `md:aspect-auto`) to match the right column's height,
so the emailError/already-subscribed messages growing that column used
to visibly grow the photo along with it. Both messages are now always
rendered with a reserved `min-h-[1.05rem]` instead of conditionally
mounted, so toggling them no longer changes the column's height at all.
## Orders & customer accounts
@@ -1172,6 +1304,24 @@ ref isn't attached to anything yet at that exact synchronous point.
`cancelled`/`return_requested`/`returned`) is maintained by hand in the
Payload admin for the shipping states — no shipping-carrier API
integration.
- **A `cancelled` Stripe order with no `invoiceNumber` never shows up
here** — that's a `pending_payment` order whose payment failed or timed
out (see the backend's `expirePendingPayments`/`confirmPayment.ts`), not
a real Storno (which is always of an already-`received`, already-
invoiced order, so it always has an `invoiceNumber`). From the
customer's point of view a payment that never went through was never
really an order, so `getCustomerOrders`/`getCustomerOrderDetail`
(`app/lib/customerAuth.ts`) filter these out by default — the row still
exists in Payload for admin/audit purposes (shown there as "Zahlung
fehlgeschlagen", see the backend's own README), just not surfaced to
the customer. `getCustomerOrderDetail` takes this as an **optional**
4th param, defaulting `false``/api/checkout/status/route.ts`'s
post-payment polling deliberately calls it unfiltered, since that flow
needs to keep seeing exactly this order (to show "Zahlung
fehlgeschlagen, bitte erneut versuchen") for the one case this filter
would otherwise hide. `/api/account/export/route.ts`'s GDPR export also
opts out (`false`) — a legal completeness export can't silently drop
rows.
- **`Navbar.tsx`'s `AccountLink`** (account icon, always visible in the
header itself — not duplicated inside the mobile fullscreen menu, see
"Mobile navigation" below) is the only *always*-reachable way into
@@ -1315,13 +1465,33 @@ links to `/konto/passwort-vergessen`.
### Email templates & Live Preview
All 6 transactional emails (order confirmation, password reset, and the 4
status-change types below) read their subject/heading/body/footer wording
from Payload's `email-templates` collection — editable in the admin
without a deploy, with a Live Preview button using the exact same
mechanism as Posts/LegalPages/Testimonials (`useLivePreview()` from
`@payloadcms/live-preview-react`, already a dependency here for
`LivePostContent.tsx`).
All 9 transactional emails (order confirmation, password reset, and the 7
status-change types below — the original 4 plus `order-tracking-added`/
`order-tracking-corrected`/`order-delivered`, added 2026-07-25) read their
subject/heading/body/footer wording from Payload's `email-templates`
collection — editable in the admin without a deploy, with a Live Preview
button using the exact same mechanism as Posts/LegalPages/Testimonials
(`useLivePreview()` from `@payloadcms/live-preview-react`, already a
dependency here for `LivePostContent.tsx`).
**Live Preview only reliably updates when popped out into its own
browser tab/window, not in the embedded admin panel.** Root cause: the
preview goes through `buildPreviewUrl()` on the Payload side, which hits
`/api/preview` on this frontend's own origin to enable Next.js Draft
Mode via a cookie — but admin (`payload.mk360.de`) and frontend
(`einfach-produktiv.mk360.de`) are different origins, so from inside the
admin's embedded `<iframe>` that's a cross-site/third-party context.
Modern browsers (Safari by default, Chrome/Firefox increasingly)
block or partition cookies set inside a cross-site iframe regardless of
which domain actually issued them, so the Draft Mode cookie doesn't
reliably persist there. Once popped into its own window it's a top-level
navigation, not a third-party context, so the cookie sets normally and
everything works. Affects every Live-Preview-enabled collection in this
system (Posts/LegalPages/Testimonials too), not just email templates —
a structural consequence of running admin and frontend on separate
domains, not something fixable at the collection-config level. A real
fix would mean serving both under one domain (reverse proxy path) rather
than two subdomains.
- **`app/lib/emailTemplates.ts`** — pure string-building functions
(`renderOrderConfirmationHtml()`, `renderPasswordResetHtml()`,
@@ -1346,8 +1516,16 @@ mechanism as Posts/LegalPages/Testimonials (`useLivePreview()` from
- The *real* send always reads the **published** template
(`getEmailTemplate()` in `app/lib/payload.ts`, `draft` unset) — a Live
Preview edit never affects a live customer email until actually saved.
- **`active` toggle (2026-07-25)** — each row now has an `active`
checkbox (backend `EmailTemplates.ts`); off suppresses the send
entirely, checked here for `order-confirmation`
(`orderEmail.ts`'s `sendOrderConfirmationEmail()`) before the hardcoded
default-wording fallback ever applies. See the backend repo's own
README ("Email templates: active/inactive toggle") for the full
picture, including the `password-reset` exception (Payload's core
`forgotPassword` operation has no hook to actually cancel that send).
- `npx payload run src/seed-email-templates.ts` (Payload repo) seeds
defaults for all 6 rows — deliberately on-brand and a little playful
defaults for all 9 rows — deliberately on-brand and a little playful
("Bestellt!" / "Kein Drama." / "Unterwegs!" / "Storniert." / "Alles
klar." / "Alles erledigt.", not generic transactional-email
boilerplate), matching this site's voice elsewhere (see e.g. the
@@ -1639,6 +1817,161 @@ 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.
## Structural breakpoint moved to 640px (2026-07-29)
The Tablet layout (768-1023px) was still not landing right after the
2026-07-24 fixes above: real tablets (768px+) hit the structural `md:`
grid switch at the exact viewport width where the fluid `clamp()` tokens
(`app/lib/fluid.ts`) were already at their smallest (floor) value, leaving
no room to shrink — cramped images/text in 2-column sections. Fixed by
moving both the fluid floor and the structural switch down together, from
768px to Tailwind's `sm:` (640px):
- **`app/lib/fluid.ts`**: `MIN_VW` 768 → 640. All `clamp()` values in
`app/globals.css`'s `@theme`/`:root` blocks are hand-authored (no build
step calls `fluid()`), so they were individually regenerated for the new
640→1440 range, not just re-anchored by editing the constant.
- **Hero/About/Newsletter/Footer/Tools** — the `md:`+`lg:` two-tier
patchwork from the 2026-07-24 fixes above collapsed back onto a single
`sm:` structural line (grid/flex switches now happen at 640px, aligned
with the new fluid floor). Content-sizing exceptions that still don't
fit at 640px (Hero/Tools' fixed-size CTA/text/icon swaps, Newsletter's
input+button row, About's inner quote/bio row) stay gated behind `lg:`,
documented in each file as a deliberate exception, not a leftover.
- Every other component with a plain `md:` structural switch (TrustRow,
Blog, TestimonialsGrid, ProductSpotlight, ProductGrid, RelatedProducts,
NewsletterModal, `/blog`, `/not-found`, both `HowItWorks` components)
renamed mechanically to `sm:`.
- Components with a genuine fixed-width constraint (Cart/Checkout's
two-column split, `SectionTOC.tsx` + the 5 legal pages, the newsletter/
todo-cards hero/benefits sections, `/challenge`) stay on `lg:` — the
math still doesn't fit at 640px either, only their code comments'
"768px floor" language was updated.
- **`Navbar.tsx`** is a deliberate, documented exception to the whole
migration — its `md:`/`lg:` 3-tier scheme (hamburger-only, hamburger+
inline-CTAs, full-inline) is about horizontal nav-link overflow, a
different failure mode than the vertical grid/flex reflows everywhere
else, with no fluid token that would make a full inline nav fit at
640px.
No browser/screenshot tool is available in this environment, so this
migration is verified by typecheck/build/lint/`test:unit` only — actual
visual confirmation at 640/768/1024/1440px is still owed by the user.
**Update, same day:** a real headless-browser check turned out to be
possible after all — `npx playwright install chromium` in a scratch
directory, then a plain Node script driving `playwright`'s `chromium.launch()`
against the *live* site at a specific viewport, reading
`document.documentElement.scrollWidth` vs. `window.innerWidth` (a real
horizontal overflow, not just "looks cramped") and `getBoundingClientRect()`
on specific elements. This is a real, repeatable verification tool for
future Tablet-layout work in this environment — see the two regressions
below, both caught this way, not by guessing from a screenshot.
Same session: **`Hero.tsx`'s heading** shortened from two sentences
("Verliere dich nicht im Mehr. Finde heraus, was wichtig ist.") to one
("Finde heraus, was wichtig ist") with no trailing period, since the
brand's orange dot (`PopIn`) already renders one, animated, right after
it. The homepage's **"3x3-System" werkzeuge-card** description was also
lengthened to match the other two cards' length (see the Payload repo's
own README for the `fix-3x3-copy-length.ts` one-off that changed the
live content).
**Correction, 2026-07-29 (later the same day): `Footer.tsx`'s legal-links
row and `TrustRow.tsx` were wrongly reclassified as plain `sm:` renames
above.** Both had originally used `lg:flex-row` for a genuine
fixed-content-width reason (logo + handle + 5 `whitespace-nowrap` legal
links; multiple `whitespace-nowrap` title+description trust badges) — not
the "grid arrives before the fluid floor" bug the `sm:` migration
targets. Renaming them to `sm:` during the mechanical mass-rename pass
caused a **real horizontal page overflow** in the 640-1023px band on
every page that renders them (Footer: every page; TrustRow: shop, cart,
checkout, order-confirmation, `/agb`, `/widerruf`, `/not-found`) —
confirmed via the Playwright method above: `scrollWidth` exceeded the
viewport by ~80-100px at 666-768px viewports. Both reverted to `lg:`,
matching their original (correct) behavior. **Lesson: "this looks
like the same md:→sm: pattern as everything else" isn't sufficient
justification on its own — check whether a component was *already* on
`lg:` for a real fixed-width-content reason (not just inherited from an
earlier, unrelated Tablet fix) before reclassifying it as a mechanical
rename.** See the `figma-to-nextjs` skill's Gotcha 21 for the fuller
writeup.
**Follow-up, same day: `TrustRow.tsx`'s alignment went through three more
rounds after the `lg:` revert above, all centered on the same
underlying flexbox lesson (skill Gotcha 22).** First, centering its
badges when stacked (per feedback) was implemented as plain `items-center`
directly on the flex-col container — but `align-items:center` centers
*each item independently* within the container's own width, so the 3
badges (different title/description lengths) ended up with 3 different
left edges instead of lining up with each other (confirmed via a real
screenshot: icons at different x-positions). Fixed by wrapping all
badges in one inner shrink-to-fit group with `items-start` internally
(so they share one left edge), then centering/left-aligning that single
group as a unit. **Then tried replacing that whole approach** with `flex
flex-wrap justify-center` directly on the badges (no inner wrapper, no
breakpoint at all): flexbox does correctly center each *wrapped line* as
a group (no misalignment bug), and it removes the overflow risk without
a `lg:`-only breakpoint, so badges flowed as many-per-row as fit (1 per
row on a narrow phone, 2-with-the-3rd-below once there's room, all 3 in
a row at Desktop). **Reverted the same day** once actually screenshotted:
with exactly 3 badges, the 2-per-row-plus-1-wrapped layout put the lone
third badge off to one side, aligned under neither badge above it —
read as "durcheinander"/disorganized rather than clean. Back to the
inner-shrink-wrap single-column approach (strict 1-per-row below `lg:`,
which stays visually tidy regardless of badge count). **Lesson:**
`flex-wrap` is a real, correct fix for the alignment/overflow problem,
but an odd item count wrapping into a partial last row is its own
separate aesthetic risk, worth a real screenshot at the exact width
where the wrap count changes before trusting it as final. `Footer.tsx`'s
legal-links row was left on its plain `lg:` fix throughout — never
asked to change, and its content (nowrap links, not title/description
pairs) doesn't have the same "which items share a line" concern.
**Also same day: `NewsletterModal.tsx`'s close button** was `position:
absolute` inside the dialog's own `overflow-y-auto` scroll container, so
scrolling the modal's content scrolled the close button away with it.
Switched to `position: sticky` (`sticky top-6 ml-auto mr-6 -mb-6`) so it
stays pinned to the top-right corner of the visible (scrolled) area —
see the skill's Gotcha 23.
## Tests
`npm run test:unit` (Vitest, `node` environment, no jsdom/Next.js runtime
@@ -1666,6 +1999,215 @@ field-lock security logic is still tested in the **Payload backend's**
own `test:unit` — see that repo's README's own "Tests" section, since
that's where that logic actually lives.
## 2026-07-30 changes
**Blog posts can have multiple categories.** `BlogPost.categories` is now
`string[]` (was a single `category: string`), joined with `", "` wherever
a single category used to render (`/blog`, `/blog/[slug]`,
`LivePostContent.tsx`, `components/Blog.tsx`). Backend field is
`Posts.categories`, a `hasMany` relationship — see the Payload backend's
own README.
**Checkout's "Abweichende Lieferadresse" gained Firma + Kontakt-E-Mail/
Telefon** (all optional) — handed to the shipping carrier, not used for
any customer communication (that stays the account email above). Backed
by new `Orders.shipping*` fields with no shipping-side `vatId` (billing-
only concept, deliberately not mirrored).
**The customer profile (`/konto/profil`) can now store its own shipping
address**, mirroring the checkout's own "Abweichende Lieferadresse"
section field-for-field (same checkbox pattern, same fields incl. the new
Firma/Kontakt fields above) — prefills the checkout section for a
returning customer instead of always starting blank. Backed by
`Customers`' new "Lieferadresse" tab in the Payload backend.
**Products can have an optional SKU** even without variants
(`Products.sku` — variants already had their own). Snapshotted onto each
order item at checkout (`app/api/checkout/route.ts` — variant SKU takes
precedence over the product's own) and shown as "Art.-Nr." on the invoice
PDF, the order-confirmation email, and `/konto/bestellungen/[orderNumber]`.
**`@einfach-produktiv/invoicing` bumped to 0.2.7** for the SKU +
shipping-contact rendering above — re-run `npm install
@einfach-produktiv/invoicing` after pulling if your local `node_modules`
predates this.
Full plan for an n8n-driven blog-post content-automation pipeline (not yet
built) lives in the Payload backend repo's `docs/blog-automation-plan.md`,
not duplicated here since it changes independently of this frontend.
**Switching an unpaid Überweisung order to Stripe.** New "Zahlungsart
ändern" button on `/konto/bestellungen/[orderNumber]`, shown when an order
is still `paymentProvider: 'manual'`, `status: 'received'`,
`paymentStatus` not yet `'paid'`, and an active Stripe payment method
exists. `POST /api/account/orders/[orderNumber]/switch-to-stripe` creates
a real PaymentIntent (`app/lib/payments`, same code checkout itself uses)
and hands it to the backend's `switchPaymentToStripeEndpoint`. Reuses
`PaymentStep` (checkout's own Stripe collection UI) and
`/checkout/verarbeitung`'s polling page — both now take a
`returnContext`/`context` prop/param so payment confirmation lands back
on the order page instead of clearing the cart and redirecting to
`/bestellbestaetigung`, which would be wrong for an order that was
already placed and confirmed.
Also: a payment status badge (Offen/Bezahlt/…) next to the existing
fulfillment status badge, on both the order list (`/konto/bestellungen`)
and the detail page — "Offen" renders in the same red/subtle-background
style as a failed status, not the neutral grey the fulfillment status
badge uses for its own "in progress" states, since an unpaid order is
worth visually flagging. A one-line mention of the switch option was
added to the Vorkasse unpaid notice in the order-confirmation email,
shown only when `hasOnlinePaymentOption` is true (an active Stripe method
actually exists).
**Fixed the email-preview page** (`/email-preview/[type]`) — it rendered
the email HTML (a full `<body>...</body>` fragment) via
`dangerouslySetInnerHTML` into a plain `<div>`, nesting it inside the
page's own already-existing `<body>` — invalid HTML the browser silently
mangled, which is why the preview looked broken (wrong background/
padding/font). Now renders via `<iframe srcDoc>`, giving the email its
own real document context. Also added a second sample fixture
(`SAMPLE_ORDER_MANUAL`) with a toggle, since `SAMPLE_ORDER` alone always
had `isManualPayment: false` — the Vorkasse/Überweisung branch (and its
new switch-option mention) was never previewable in the admin at all
before this.
**Follow-up same day: switch-to-stripe eligibility narrowed.**
`canSwitchPayment` (page.tsx) and the `switch-to-stripe` route's own
re-check both changed from `paymentStatus !== "paid"` to an explicit
allowlist (`"pending"` or `"not_applicable"`) — see the Payload backend's
own README for why the blocklist form was too permissive. No visible
behavior change for the normal case, just closes an edge case
(`"failed"`/`"refunded"`/`"partially_refunded"` on a manual order — not
realistic today, but not meaningful states to switch *from* either).
**Two more same-day fixes:**
- `customerOrderAction()` now only offers "Rücksendung anfragen" once an
order is actually `delivered`, not already at `shipped` — a (partial)
return before the package has even arrived doesn't make sense yet. The
backend's own `CUSTOMER_ALLOWED_TRANSITIONS` still technically permits
`shipped``return_requested` too (an extensive existing test suite is
built around that as its base fixture, see the Payload backend's
README) — this only narrows what the UI itself offers, a stricter
subset of what the backend already allows, not a security boundary
being loosened.
- `/impressum`'s "Angaben zum Anbieter" email (`AnbieterAngaben.tsx`) was
plain text, not a `mailto:` link — the only email address on the whole
site that wasn't clickable, since this one section is rendered
straight from `company-settings` fields rather than through the CMS
richText renderer (which already links emails/URLs correctly).
**New `sendPaymentSwitchedEmail()`** (`lib/orderEmail.ts`) — sent instead
of `sendOrderConfirmationEmail()` when `confirmPaymentEmail.ts` sees
`order.paymentSwitchedAt` set (a payment confirmation that came from
switching an existing Überweisung order to Stripe, not a fresh gated
checkout — see the Payload backend's own README on
`switchPaymentToStripeEndpoint`). Same shape as the order-status emails
(icon + admin-editable heading/bodyText + "Bestellung ansehen" button via
`renderOrderStatusHtml`), no item table, but **does** re-attach a freshly
generated invoice PDF — same `invoiceNumber` as always (never re-issued
for a switch), but `paymentMethodTitle` now reflects the actually-
confirmed instrument, which changes `@einfach-produktiv/invoicing`'s own
`isPaidImmediately()` check from the Vorkasse notice to "✓ Bereits
beglichen", so the customer's copy of "their invoice" should reflect
that. `buildInvoiceAttachment()` was extracted out of
`sendOrderConfirmationEmail()` so both senders share the exact same PDF-
generation call instead of duplicating that ~40-line object literal.
Default fallback copy (used whenever no admin template is saved/active)
matches the existing brand voice (`sendOrderConfirmationEmail`'s own
"Bestellt! Deine Ruhe kann kommen 🎉" fallback) rather than a flat
system-notice tone: *"Erledigt! Deine Zahlung ist da 🎉" / "Deine Zahlung
ist gerade bei uns eingetrudelt — ab jetzt läuft alles automatisch
weiter, du musst dich um nichts mehr kümmern."* New
`payment-method-switched` `EmailTemplateType`, added to
`/email-preview`'s valid types too.
**Bumped `@einfach-produktiv/invoicing` to 0.2.8** — fixes a font-path
bug that broke every invoice/correction-invoice PDF render *inside the
Payload backend* specifically. See that package's own README for the
Turbopack asset-hashing root cause. (This turned out to be an
incomplete picture — see "Cart bugs" below: 0.2.8 itself broke this
frontend's own client bundle, just not in a way anyone noticed yet at
the time this was written.)
## Order list mobile grid (2026-07-30)
`/konto/bestellungen`'s order cards used plain `flex flex-wrap` for their
6 fields (Bestellnummer, Datum, Artikel, Status, Zahlungsstatus,
Gesamtbetrag), which sized each field to its own content width — so e.g.
"Status"/"Zahlungsstatus" started at a different x position from one
order card to the next depending on how wide that particular badge label
happened to be. Replaced with a single CSS Grid per card
(`grid grid-cols-2 min-[500px]:grid-cols-4`), since a grid's column
tracks are fixed by the container width (identical on every card
regardless of content) rather than by content width.
Went through several rounds of visual feedback:
- Below 500px there just isn't room for 4 tracks without badge labels
like "In Bearbeitung" overflowing their column, so it drops to 2
columns there. The 6 fields in DOM order already tile cleanly into 3
rows of 2 (Bestellnummer/Datum, Artikel/Status,
Zahlungsstatus/Gesamtbetrag) without any reordering.
- Bestellnummer/Datum deliberately stay at `col-span-1` in both the
2-column and 4-column layouts, side by side even below 500px — a
brief `col-span-2` experiment (own full-width row each, to avoid the
order number wrapping on very narrow phones) was reverted since it
read worse than the occasional wrap.
- Artikel forces `col-start-1` so it wraps to its own row in the
4-column layout instead of Grid's auto-placement flowing it into the 2
cells left empty in row 1 after Bestellnummer/Datum (a harmless no-op
in the 2-column layout, where it's already first in row 2 anyway).
- The breakpoint moved twice during tuning — first sm (640px) → md
(768px) → lg (1024px) while the intent was still "keep 2 columns as
long as possible," then flipped entirely to "stay at 4 columns as long
as possible" once the actual goal was clarified, then settled on a
content-driven `min-[500px]` (started at 400px, moved to 500px) once a
real device screenshot showed 4 columns overflowing on a narrow phone.
- `OrderStatusBadge`/`PaymentStatusBadge` gained `self-start` — as flex
children of a `flex flex-col` parent (default `align-items: stretch`),
the badge `<span>` was stretching to the parent's full width despite
being `inline-flex` itself (flex items are blockified, so `stretch`
still applies); `self-start` keeps the badge background only as wide
as its label.
## Cart bugs (2026-07-30)
Two independent bugs, both reported together as "cart count keeps going up
after every logout/login, and `/cart` won't open":
**1. Quantities doubled on every logout/login cycle.** `LogoutButton.tsx`
never cleared the local cart (`ep_cart` in `localStorage`), and
`mergeServerCartIntoLocal()` (`lib/cart.ts`, called after every login) adds
the server-side cart's quantities into the existing local ones
(`existing.qty += qty`) rather than replacing them — correct behavior for
folding in genuine guest-cart additions, wrong once `CartSync.tsx` has
already mirrored the local cart to the server. Since both sides held the
same quantities at logout time, every login added them together, doubling
the total each cycle. Fixed by clearing the local cart on logout — the next
login's merge then starts from empty (or only genuinely new guest-session
items) instead of re-adding already-synced quantities. `readCart()`/
`getCart()` also gained an `Array.isArray()` check after `JSON.parse`
(previously only guarded against parse *syntax* errors, not the parsed
value being some other shape) as a general defensive hardening, though this
turned out not to be the `/cart` crash's actual cause — see below.
**2. `/cart` couldn't open: `@einfach-produktiv/invoicing`'s `fonts.ts`
crashed the client bundle.** Unrelated to bug 1 — this frontend's own
`CartContent.tsx` imports `computeTaxBreakdown` from
`@einfach-produktiv/invoicing`'s barrel `index.ts`, which also re-exports
`invoicePdf.tsx`, which imports `fonts.ts`. The same-day v0.2.8 fix in that
package (see the entry above) made `fonts.ts` call `fileURLToPath` at
module scope unconditionally — fine in Node.js, but `node:url`'s
`fileURLToPath` isn't a real function in a browser bundle's polyfilled
shim, so **every** client bundle that transitively reached this module
crashed on module evaluation (`Uncaught TypeError: fileURLToPath is not a
function`), including `/cart`'s entire client-rendered page. Fixed
upstream in `@einfach-produktiv/invoicing` 0.2.9 (see that package's own
README) by branching on `typeof window === "undefined"` — browser keeps
the original `new URL(..., import.meta.url)` idiom, Node.js keeps the
`fileURLToPath` resolution. Re-ran `npm install @einfach-produktiv/invoicing`
here to pick it up.
## Deployment
- **Dockerfile**: 3-stage build (`deps``builder``runner`) with
@@ -1676,6 +2218,133 @@ that's where that logic actually lives.
bridge → Coolify API → rebuild + restart. Full mechanics in
`~/dev/README.md`.
## Wishlist, search, filters, feature toggles (2026-07-30 evening)
A cluster of new features, all gated behind new `CompanySettings`
booleans (Payload admin → Company Settings → **Features** tab, all off
by default — each feature stays entirely invisible in the frontend
until explicitly switched on): `wishlistEnabled`, `searchEnabled`,
`shopFilterEnabled`, `blogFilterEnabled`, `orderFilterEnabled`.
**Wishlist.** Backend `wishlist-items` collection (customer + product +
optional variant, unique per combination, customer-scoped access — see
the Payload backend's own README). Frontend: `useWishlist.ts` (a
fetch-based hook with an optimistic toggle + a `window` event so every
`WishlistButton`/the Navbar badge on a page stay in sync — not
`localStorage`-backed like the cart, since a wishlist needs a logged-in
customer to mean anything), `WishlistButton.tsx` (heart icon, redirects
to login with `?redirect=` if logged out), the Navbar's heart icon +
count badge (hidden below `sm:` — Account+Cart are the only
always-visible icons on true mobile, a 3rd icon there risks the nav-
overflow bug class documented in the `figma-to-nextjs` skill), and
`/konto/merkliste` (a Client Component grid — `MerklisteGrid.tsx` — so
removing an item disappears immediately, unlike a plain Server
Component render which wouldn't react to the client-side toggle at all).
`Product` gained a `numericId` field (the raw Payload id) alongside its
existing slug `id`, since `wishlist-items.product` is a real numeric
relationship, unlike the cart/checkout's slug-keyed "commerce id".
Found and fixed a real race condition in `useWishlist.ts`: the "refetch
me" event used to fire immediately after the optimistic local update,
before the POST request was even sent — another instance's resulting
refetch could beat the actual server write, cache the pre-toggle list,
and never get told to refetch again (the Navbar count stayed one
behind). The event now only fires after the request settles.
**Standalone registration.** Until now an account only ever got created
inline during checkout. That stopped making sense the moment a
wishlist gave accounts a reason to exist independent of a purchase —
new `/konto/registrieren` (+ `RegisterForm.tsx`, reusing the existing
`/api/account/register` route). `LoginForm.tsx` now also honors a
`?redirect=` param (previously ignored, always landing on
`/konto/bestellungen`) and links to the new page instead of implying
"only via checkout".
**Instant search.** New `/api/search` route — plain Payload
`where[...][contains]` queries (Postgres `ILIKE`) against Products +
Posts, not a real search index (Meilisearch/Algolia); fine at this
catalog's current size, worth upgrading once it grows past ~20 products
(see the e-commerce SOTA gaps memory note). `SearchOverlay.tsx`
(debounced 250ms, grouped results) + a Navbar search icon (same
`hidden sm:` reasoning as the wishlist icon).
**Filters, everywhere URL-search-param-driven** (shareable/bookmarkable,
no client-side-only state):
- `/konto/bestellungen` — status/paymentStatus/year, via 3
`CustomSelect` dropdowns (`OrderFilters.tsx`). Went through two
earlier UI iterations first: a wall of filter chips (too cluttered,
too much vertical space) and then plain native `<select>`s (a native
select's open options popup can't be styled at all — looked
completely off-brand) before landing on `CustomSelect.tsx`.
- `/blog` — category toggle chips (`buildCategoryHref()`), plain
server-rendered `<Link>`s, no client component needed since the page
already fetches every published post in one request.
- `/shop` — a price min/max range (`PriceRangeFilter.tsx`) — preset
toggle buckets were tried first and reverted ("keine toggle badges").
A fuller sidebar-with-drag-slider layout was requested as a follow-up,
not yet built as of this writing.
**`CustomSelect.tsx`** (`app/components/`) — a fully custom-styled
dropdown (own trigger + own `role="listbox"` options panel, keyboard
nav, click-outside-to-close), built to replace native `<select>`s
wherever their un-stylable options popup matters. Also used for
checkout's two country pickers now (`includeAllOption={false}` — a
country is always genuinely selected, no "clear" state like the
filter-dropdown use case; `fullWidth` — matches the surrounding w-full
form fields instead of the filter bar's shrink-to-fit sizing).
**Assorted same-evening bugfixes:**
- Blog category filter bar was invisible (wrapped in `<Reveal>`, which
sits right at the hero's bottom edge — exactly the
`whileInView(margin:"-80px")` trap `Reveal.tsx`'s own comment
documents: an element already visible without scrolling can
permanently never register as "entered view"). Now a plain `div`.
- Then found rendering *behind* the featured-post card instead (that
card pulls itself up `-mt-8`/`z-10` to overlap the *hero's* bottom
edge — its original design — but the filter bar now sat where that
pull landed). Given `relative z-20` to stay above it.
- `AddToCartInlineButton`'s `className` prop *replaces* its whole
default styling (`?? default`, not a merge) — `/konto/merkliste`
passed `className="w-full"` and lost all the button's actual styling
as a result. Removed the override (the default is already `w-full`).
**How to apply if you're reading this cold:** every one of these
features requires the matching `CompanySettings` toggle to actually be
switched on before it shows up anywhere in the frontend — if a feature
"isn't showing," check that first.
## DHL checkout integrations (2026-07-31)
Three checkout-facing pieces added, each invisible unless the tenant's
backend `dhl-settings` has the matching toggle on (checked indirectly —
the calls below 404 gracefully when a tenant hasn't activated a given DHL
sub-integration, same "no half-built UI" convention as the feature
toggles above). Full backend-side documentation:
`docker/payload`'s `src/lib/shipping/README.md`.
- **`app/lib/shippingDhl.ts`** — thin client for the backend's 2 public
DHL endpoints (Postnummer validation, DataFactory address autocomplete).
Own copy, no shared package — same convention as `app/lib/tracking.ts`.
- **`app/api/checkout/validate-dhl-postnumber/route.ts`** and
**`app/api/checkout/autocomplete-address/route.ts`** — Next.js API
routes proxying to the backend. Required because `CheckoutContent.tsx`
is a Client Component and DHL credentials are tenant-specific (live in
Payload) — unlike VIES (`validate-vat/route.ts`), the browser can never
call Payload's DHL endpoint directly.
- **`app/checkout/components/AddressAutocomplete.tsx`** — wraps the
billing and shipping street inputs with a debounced DHL DataFactory
suggestion dropdown; selecting a suggestion also fills zip/city.
- **Postnummer live validation** — `CheckoutContent.tsx`'s
`handlePostNumberBlur` (mirrors `handleVatIdBlur`'s shape/status-state
pattern) checks the Packstation Postnummer against DHL on blur, once the
existing format check (610 digits) already passes.
- **Return-label download** — `/konto/bestellungen/[orderNumber]` shows
a "Retourenschein herunterladen" link once the backend has generated a
DHL return label for that order (`order.dhlReturnLabelMedia`, resolved
via the new `getMediaUrlById()` in `app/lib/payload.ts` — the order
fetch itself stays `depth=0` for unrelated reasons, see
`getCustomerOrderDetail`'s own comment).
## Related design source
- `~/dev/einfach-produktiv/mockups/` — Figma-stage mockup PNGs
+2 -2
View File
@@ -10,9 +10,9 @@ export async function GET() {
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const profile = await getCustomerProfile(session.token);
const orderSummaries = await getCustomerOrders(session.token, session.customer.id);
const orderSummaries = await getCustomerOrders(session.token, session.customer.id, false);
const orders = await Promise.all(
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber)),
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber, false)),
);
const payload = {
@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
import { getPaymentMethods, groupPaymentMethodsForCheckout } from "../../../../../lib/payload";
import { paymentProvider, isPaymentTestMode } from "../../../../../lib/payments";
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const PAYMENT_WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET || "";
// Lets a logged-in customer move an existing, still-unpaid Überweisung
// order onto a Stripe PaymentIntent instead of waiting on their own bank
// transfer — see the backend's switchPaymentToStripe.ts for the matching
// endpoint and why this needs a dedicated backend route rather than the
// generic customer-JWT order-PATCH path (paymentProvider/paymentStatus
// are system fields a customer JWT can never touch).
export async function POST(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const { orderNumber } = await params;
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
// Same eligibility the backend endpoint re-checks authoritatively —
// checked here too for a friendly error instead of a bare 409. An
// explicit allowlist ('pending'/'not_applicable'), not a
// paymentStatus !== "paid" blocklist — see switchPaymentToStripe.ts's
// own comment on why 'failed'/'refunded'/'partially_refunded' aren't
// meaningful states to switch from either.
const eligiblePaymentStatus = order.paymentStatus === "pending" || order.paymentStatus === "not_applicable";
if (order.paymentProvider !== "manual" || order.status !== "received" || !eligiblePaymentStatus) {
return NextResponse.json({ ok: false, reason: "Die Zahlungsart kann für diese Bestellung gerade nicht geändert werden." }, { status: 400 });
}
// Only offer this when a real Stripe payment method is actually active
// — same "Online-Zahlung" grouping the checkout itself uses, so this
// never presents an option that checkout wouldn't currently accept either.
const methods = groupPaymentMethodsForCheckout(await getPaymentMethods());
const hasStripeOption = methods.some((m) => m.provider === "stripe");
if (!hasStripeOption) {
return NextResponse.json({ ok: false, reason: "Aktuell steht keine Online-Zahlung zur Verfügung." }, { status: 400 });
}
let intent: { clientSecret: string; providerReference: string };
try {
intent = await paymentProvider.createPaymentIntent({
amountCents: Math.round(order.total * 100),
currency: "eur",
customerEmail: order.customerEmail,
description: `Bestellung ${order.orderNumber}`,
});
} catch (err) {
return NextResponse.json({ ok: false, reason: "Zahlung konnte nicht vorbereitet werden." }, { status: 500 });
}
const res = await fetch(`${PAYLOAD_URL}/api/orders/${order.id}/switch-payment-to-stripe`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-payment-webhook-secret": PAYMENT_WEBHOOK_SECRET },
body: JSON.stringify({ providerReference: intent.providerReference }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
return NextResponse.json({ ok: false, reason: data?.reason ?? "Umstellung fehlgeschlagen." }, { status: 400 });
}
// Best-effort, same as checkout's own call — a failure here doesn't
// block the payment itself, only the confirm-payment webhook's metadata
// lookup, which the frontend's own webhook route already alerts on.
await paymentProvider.attachOrderMetadata(intent.providerReference, { orderId: String(order.id), orderNumber: order.orderNumber }).catch(() => {});
return NextResponse.json({
ok: true,
clientSecret: intent.clientSecret,
orderId: order.id,
orderNumber: order.orderNumber,
testMode: isPaymentTestMode,
...(isPaymentTestMode ? { providerReference: intent.providerReference } : {}),
});
}
+66 -11
View File
@@ -13,13 +13,36 @@ export async function PATCH(request: Request) {
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const body = await request.json().catch(() => null);
const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country, companyName, vatId } = body ?? {};
const {
firstName,
lastName,
street,
zip,
city,
country,
companyName,
vatId,
hasDifferentShippingAddress,
shippingFirstName,
shippingLastName,
shippingCompanyName,
shippingDeliveryMethod,
shippingStreet,
shippingPackstationNumber,
shippingPostNumber,
shippingZip,
shippingCity,
shippingCountry,
shippingContactEmail,
shippingContactPhone,
} = body ?? {};
if (
typeof firstName !== "string" ||
!firstName ||
typeof lastName !== "string" ||
!lastName ||
(deliveryMethod !== "address" && deliveryMethod !== "packstation") ||
typeof street !== "string" ||
!street ||
typeof zip !== "string" ||
!zip ||
typeof city !== "string" ||
@@ -29,12 +52,6 @@ export async function PATCH(request: Request) {
) {
return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder ausfüllen." }, { status: 400 });
}
if (deliveryMethod === "address" && !street) {
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer angeben." }, { status: 400 });
}
if (deliveryMethod === "packstation" && (!packstationNumber || !postNumber)) {
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 });
}
// Both independently optional (see Customers.ts's own comment) — only
// format-checked when actually provided, same as the backend field itself.
const normalizedVatId = typeof vatId === "string" && vatId ? normalizeVatId(vatId) : undefined;
@@ -42,18 +59,56 @@ export async function PATCH(request: Request) {
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 });
}
// Same shape as the checkout's own shipping-address-override validation
// (api/checkout/route.ts) — required fields only apply when the toggle
// is actually on, since this whole block is optional otherwise.
if (hasDifferentShippingAddress) {
if (
typeof shippingFirstName !== "string" ||
!shippingFirstName ||
typeof shippingLastName !== "string" ||
!shippingLastName ||
(shippingDeliveryMethod !== "address" && shippingDeliveryMethod !== "packstation") ||
typeof shippingZip !== "string" ||
!shippingZip ||
typeof shippingCity !== "string" ||
!shippingCity ||
typeof shippingCountry !== "string" ||
!shippingCountry
) {
return NextResponse.json({ ok: false, reason: "Bitte alle Pflichtfelder der Lieferadresse ausfüllen." }, { status: 400 });
}
if (shippingDeliveryMethod === "address" && !shippingStreet) {
return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer der Lieferadresse angeben." }, { status: 400 });
}
if (shippingDeliveryMethod === "packstation" && (!shippingPackstationNumber || !shippingPostNumber)) {
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer der Lieferadresse angeben." }, { status: 400 });
}
}
const result = await updateCustomerProfile(session.token, session.customer.id, {
firstName,
lastName,
deliveryMethod,
deliveryMethod: "address",
street,
packstationNumber,
postNumber,
zip,
city,
country,
companyName: typeof companyName === "string" && companyName ? companyName : undefined,
vatId: normalizedVatId,
hasDifferentShippingAddress: Boolean(hasDifferentShippingAddress),
shippingFirstName: hasDifferentShippingAddress ? shippingFirstName : undefined,
shippingLastName: hasDifferentShippingAddress ? shippingLastName : undefined,
shippingCompanyName: hasDifferentShippingAddress && shippingCompanyName ? shippingCompanyName : undefined,
shippingDeliveryMethod: hasDifferentShippingAddress ? shippingDeliveryMethod : undefined,
shippingStreet: hasDifferentShippingAddress ? shippingStreet : undefined,
shippingPackstationNumber: hasDifferentShippingAddress ? shippingPackstationNumber : undefined,
shippingPostNumber: hasDifferentShippingAddress ? shippingPostNumber : undefined,
shippingZip: hasDifferentShippingAddress ? shippingZip : undefined,
shippingCity: hasDifferentShippingAddress ? shippingCity : undefined,
shippingCountry: hasDifferentShippingAddress ? shippingCountry : undefined,
shippingContactEmail: hasDifferentShippingAddress && shippingContactEmail ? shippingContactEmail : undefined,
shippingContactPhone: hasDifferentShippingAddress && shippingContactPhone ? shippingContactPhone : undefined,
});
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { getSessionCustomer } from "../../../lib/customerAuth";
import { getWishlist, toggleWishlistItem } from "../../../lib/customerAuth";
export async function GET() {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ items: [] }, { status: 401 });
const items = await getWishlist(session.token, session.customer.id);
return NextResponse.json({ items });
}
export async function POST(request: Request) {
const session = await getSessionCustomer();
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const body = await request.json().catch(() => null);
const productId = Number(body?.productId);
const variant = typeof body?.variant === "string" ? body.variant : "";
if (!Number.isInteger(productId) || productId <= 0) {
return NextResponse.json({ ok: false, reason: "Ungültiges Produkt." }, { status: 400 });
}
const result = await toggleWishlistItem(session.token, productId, variant);
if (!result.ok) return NextResponse.json({ ok: false, reason: "Merkliste konnte nicht aktualisiert werden." }, { status: 500 });
return NextResponse.json({ ok: true, wishlisted: result.wishlisted });
}
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { autocompleteDhlAddress } from "../../../lib/shippingDhl";
// Proxies the checkout's address-autocomplete input through to Payload's
// DHL DataFactory endpoint — same reasoning as validate-dhl-postnumber's
// own route: tenant DHL credentials must never reach the browser.
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get("query") ?? "";
const suggestions = await autocompleteDhlAddress(query);
return NextResponse.json({ ok: true, suggestions });
}
+30 -4
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import type { CartItem } from "../../lib/cart";
import { getShippingMethods, getPaymentMethods, getCompanySettings } from "../../lib/payload";
import { getShippingMethods, getPaymentMethods, getCompanySettings, groupPaymentMethodsForCheckout } from "../../lib/payload";
import { validateDiscountCode, redeemDiscountCode } from "../../lib/discountServer";
import { createOrder } from "../../lib/orderServer";
import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth";
@@ -54,6 +54,9 @@ type CheckoutBody = {
shippingZip?: string;
shippingCity?: string;
shippingCountry?: string;
shippingCompanyName?: string;
shippingContactEmail?: string;
shippingContactPhone?: string;
newsletterOptIn: boolean;
};
@@ -126,7 +129,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,
@@ -160,6 +163,7 @@ export async function POST(request: Request) {
taxRatePercent: number;
bundleContents: string | null;
variantName: string | null;
sku: string | null;
}[] = [];
for (const line of body.cart) {
const product = productsBySlug.get(line.id);
@@ -168,7 +172,7 @@ export async function POST(request: Request) {
// requested variant that no longer exists on this product (removed,
// or never existed — a tampered request) fails the whole checkout
// rather than silently falling back to the base product/price.
let variant: { name: string; priceOverride: number | null } | null = null;
let variant: { name: string; priceOverride: number | null; sku: string | null } | null = null;
if (line.variant) {
variant = product.variants?.find((v) => v.name === line.variant) ?? null;
if (!variant) return NextResponse.json({ ok: false, reason: "Eine gewählte Variante ist nicht mehr verfügbar." }, { status: 400 });
@@ -195,15 +199,25 @@ export async function POST(request: Request) {
taxRatePercent: kleinunternehmer ? 0 : (product.taxRatePercent ?? defaultTaxRate),
bundleContents: describeBundleContents(product),
variantName: variant?.name ?? null,
// Variant sku takes precedence over the product's own — same
// "variant overrides product" precedence unitPrice already uses.
sku: variant?.sku ?? product.sku ?? null,
});
}
const subtotal = roundMoney(items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0));
// Products.noShippingCost — a cart made up entirely of items that opt
// out of shipping costs (e.g. purely digital downloads) never gets
// charged shipping at all, regardless of the free-shipping threshold.
// A single item WITHOUT the flag still triggers normal shipping for the
// whole order — this only exempts a product, never the whole cart just
// because it contains an exempt item.
const hasShippableItem = body.cart.some((line) => !productsBySlug.get(line.id)?.noShippingCost);
const shippingMethods = await getShippingMethods();
const shippingMethod = shippingMethods.find((m) => m.id === body.shippingMethodId);
if (!shippingMethod) return NextResponse.json({ ok: false, reason: "Versandart ist ungültig." }, { status: 400 });
const freeShipping = shippingMethod.freeShippingThreshold != null && subtotal >= shippingMethod.freeShippingThreshold;
const shippingCost = freeShipping ? 0 : shippingMethod.price;
const shippingCost = !hasShippableItem || freeShipping ? 0 : shippingMethod.price;
const paymentMethods = await getPaymentMethods();
const paymentMethod = paymentMethods.find((m) => m.id === body.paymentMethodId);
@@ -338,11 +352,15 @@ export async function POST(request: Request) {
shippingZip: body.shippingZip,
shippingCity: body.shippingCity,
shippingCountry: body.shippingCountry,
shippingCompanyName: Boolean(body.hasDifferentShippingAddress) ? body.shippingCompanyName || undefined : undefined,
shippingContactEmail: Boolean(body.hasDifferentShippingAddress) ? body.shippingContactEmail || undefined : undefined,
shippingContactPhone: Boolean(body.hasDifferentShippingAddress) ? body.shippingContactPhone || undefined : undefined,
newsletterOptIn: Boolean(body.newsletterOptIn),
items,
subtotal: finalSubtotal,
shippingCost: finalShippingCost,
shippingMethodTitle: shippingMethod.title,
shippingMethod: shippingMethod.id,
// The checkout UI collapses Kreditkarte/PayPal into one "Online-
// Zahlung" pre-selection (see groupPaymentMethodsForCheckout) — the
// customer hasn't actually chosen an instrument yet at this point,
@@ -422,6 +440,7 @@ export async function POST(request: Request) {
hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
shippingFirstName: body.shippingFirstName,
shippingLastName: body.shippingLastName,
shippingCompanyName: body.hasDifferentShippingAddress ? body.shippingCompanyName : undefined,
shippingDeliveryMethod: body.shippingDeliveryMethod,
shippingStreet: body.shippingStreet,
shippingPackstationNumber: body.shippingPackstationNumber,
@@ -429,6 +448,8 @@ export async function POST(request: Request) {
shippingZip: body.shippingZip,
shippingCity: body.shippingCity,
shippingCountry: body.shippingCountry,
shippingContactEmail: body.hasDifferentShippingAddress ? body.shippingContactEmail : undefined,
shippingContactPhone: body.hasDifferentShippingAddress ? body.shippingContactPhone : undefined,
paymentMethodTitle: paymentMethod.title,
items: items.map((i) => ({
productName: i.productName,
@@ -438,12 +459,17 @@ export async function POST(request: Request) {
taxRatePercent: i.taxRatePercent,
bundleContents: i.bundleContents,
variantName: i.variantName,
sku: i.sku,
})),
subtotal: finalSubtotal,
shippingCost: finalShippingCost,
discountAmount,
discountCode: body.discountCode || null,
total,
isManualPayment: true,
// Only meaningful for the Vorkasse notice above — whether a
// switch to Kreditkarte/PayPal is even worth mentioning right now.
hasOnlinePaymentOption: groupPaymentMethodsForCheckout(paymentMethods).some((m) => m.provider === "stripe"),
},
body.email,
).catch((err) => {
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { validateDhlPostNumber } from "../../../lib/shippingDhl";
// Called from CheckoutContent.tsx's Postnummer field blur (Packstation
// delivery). Proxied through this Next.js route rather than fetched
// directly from the client the way VIES is (see validate-vat/route.ts) —
// DHL credentials are tenant-specific and live in Payload, unlike VIES's
// public EU endpoint, so the browser must never call Payload's DHL
// endpoint (or hold its own copy of tenant credentials) directly.
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const postNumber = typeof body?.postNumber === "string" ? body.postNumber : "";
const firstName = typeof body?.firstName === "string" ? body.firstName : "";
const lastName = typeof body?.lastName === "string" ? body.lastName : "";
if (!postNumber || !firstName || !lastName) {
return NextResponse.json({ ok: false, reason: "Postnummer, Vorname und Nachname sind erforderlich." }, { status: 400 });
}
const result = await validateDhlPostNumber({ postNumber, firstName, lastName });
return NextResponse.json(result);
}
+155
View File
@@ -0,0 +1,155 @@
import React from "react";
import { Document, Page, Text, View, StyleSheet, Font, renderToBuffer } from "@react-pdf/renderer";
import { getCompanySettings } from "../../lib/payload";
// react-pdf's default hyphenation would otherwise auto-split long words at
// syllable boundaries to fit the line — including inside the seller's own
// email address (confirmed: "hallo@einfach-produktiv.com" rendered as
// "einfach-produk-tiv.com"), which must never be broken mid-word.
Font.registerHyphenationCallback((word) => [word]);
// Generated on-the-fly from live company-settings instead of a static
// uploaded PDF (see /widerruf's own download link) — the "An:"
// address used to be baked into a hand-crafted PDF and silently went
// stale the moment an admin updated the Impressum's Anbieterdaten without
// remembering to also re-export/re-upload this file by hand (exactly what
// happened 2026-07-29: the address changed in company-settings, the PDF
// still had the old one). Same source data as AnbieterAngaben.tsx's
// Impressum block, so the two can never drift apart again.
const BRAND = "#f6a701";
const DARK = "#1b1b1a";
const GREY = "#737371";
const RULE = "#d1cec4";
const styles = StyleSheet.create({
page: {
fontSize: 10.5,
color: DARK,
paddingTop: 78,
paddingBottom: 64,
paddingHorizontal: 64,
},
topBar: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 8,
backgroundColor: BRAND,
},
title: {
fontSize: 15,
fontFamily: "Times-Bold",
marginBottom: 8,
},
subtitle: {
fontSize: 20,
fontFamily: "Times-Bold",
marginBottom: 10,
},
accent: {
width: 32,
height: 2.2,
backgroundColor: BRAND,
marginBottom: 14,
},
note: {
fontSize: 9.5,
color: GREY,
lineHeight: 1.4,
marginBottom: 16,
},
label: {
fontFamily: "Helvetica-Bold",
marginBottom: 4,
},
paragraph: {
lineHeight: 1.45,
marginBottom: 4,
},
paragraphSpaced: {
lineHeight: 1.45,
marginTop: 16,
marginBottom: 4,
},
section: {
marginTop: 20,
},
sectionLabel: {
fontSize: 8.5,
fontFamily: "Helvetica-Bold",
color: GREY,
marginBottom: 8,
},
sectionRule: {
borderBottomWidth: 0.75,
borderBottomColor: RULE,
},
footerNote: {
fontSize: 8.5,
color: GREY,
marginTop: 18,
},
});
const SECTION_HEADERS = [
"BESTELLTE WARE(N) / DIENSTLEISTUNG",
"BESTELLT AM (*)",
"ERHALTEN AM (*)",
"NAME DES/DER VERBRAUCHER(S)",
"ANSCHRIFT DES/DER VERBRAUCHER(S)",
"UNTERSCHRIFT DES/DER VERBRAUCHER(S) (NUR BEI MITTEILUNG AUF PAPIER)",
"DATUM",
];
export async function GET() {
const seller = await getCompanySettings();
const addressLine = seller
? `${seller.sellerName} - ${seller.sellerStreet}, ${seller.sellerZip} ${seller.sellerCity}, E-Mail: ${seller.sellerEmail}`
: "einfach produktiv.";
const doc = React.createElement(
Document,
{ title: "Muster-Widerrufsformular - einfach produktiv." },
React.createElement(
Page,
{ size: "A4", style: styles.page },
React.createElement(View, { style: styles.topBar }),
React.createElement(Text, { style: styles.title }, "einfach produktiv."),
React.createElement(Text, { style: styles.subtitle }, "Muster-Widerrufsformular"),
React.createElement(View, { style: styles.accent }),
React.createElement(
Text,
{ style: styles.note },
"(Wenn du diesen Vertrag widerrufen möchtest, fülle bitte dieses Formular aus und sende es per Post oder als E-Mail-Anhang an uns zurück.)",
),
React.createElement(Text, { style: styles.label }, "An:"),
React.createElement(Text, { style: styles.paragraph }, addressLine),
React.createElement(
Text,
{ style: styles.paragraphSpaced },
"Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*):",
),
...SECTION_HEADERS.map((header) =>
React.createElement(
View,
{ key: header, style: styles.section },
React.createElement(Text, { style: styles.sectionLabel }, header),
React.createElement(View, { style: styles.sectionRule }),
),
),
React.createElement(Text, { style: styles.footerNote }, "(*) Unzutreffendes streichen."),
),
);
const buffer = await renderToBuffer(doc);
return new Response(buffer as unknown as BodyInit, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'inline; filename="Muster-Widerrufsformular.pdf"',
"Cache-Control": "no-store",
},
});
}
+9 -1
View File
@@ -24,7 +24,15 @@ export async function POST(req: Request) {
const source = body.source && VALID_SOURCES.includes(body.source) ? body.source : "newsletter-page";
const result = await upsertNewsletterContact(email, source);
if (!result.ok) {
// The customer-facing message stays generic on purpose (never leak
// Brevo's internal error text to a customer) — but the real reason
// was previously discarded entirely, which cost real debugging time
// tracking down a misconfigured BREVO_LIST_ID in Coolify (2026-07-25):
// every failure looked identical from the outside, whether it was a
// bad env var, a Brevo outage, or something else. Logged here so it's
// at least diagnosable from the container's own logs going forward.
console.error(`newsletter subscribe failed for source=${source}: ${result.reason}`);
return NextResponse.json({ ok: false, reason: "Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut." }, { status: 502 });
}
return NextResponse.json({ ok: true });
return NextResponse.json({ ok: true, alreadySubscribed: result.alreadySubscribed ?? false });
}
+76
View File
@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
export type SearchResult = {
type: "product" | "post";
id: string;
title: string;
href: string;
thumbnail: string | null;
};
// Lightweight instant search — plain `where[...][contains]` queries
// against Payload (Postgres ILIKE under the hood) rather than a real
// search index (Meilisearch/Algolia). Fine at this catalog size (a
// handful of products + blog posts, see [[project-ecommerce-sota-gaps]]'s
// own "search becomes necessary past ~20 products" note) — worth
// upgrading only once the catalog actually grows into that range, this
// route can be swapped out later without touching the frontend overlay.
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const q = searchParams.get("q")?.trim() ?? "";
if (q.length < 2) return NextResponse.json({ results: [] });
const productParams = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[active][equals]": "true",
"where[name][contains]": q,
depth: "1",
limit: "6",
});
const postParams = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[status][equals]": "published",
"where[title][contains]": q,
depth: "1",
limit: "6",
});
const [productsRes, postsRes] = await Promise.all([
fetch(`${PAYLOAD_URL}/api/products?${productParams}`, { next: { revalidate: 30 } }),
fetch(`${PAYLOAD_URL}/api/posts?${postParams}`, { next: { revalidate: 30 } }),
]);
const results: SearchResult[] = [];
if (productsRes.ok) {
const data: { docs?: { id: number; slug: string; name: string; detailHref: string | null; image: { url: string } | number | null }[] } =
await productsRes.json();
for (const doc of data.docs ?? []) {
results.push({
type: "product",
id: `product-${doc.id}`,
title: doc.name,
href: doc.detailHref || "/shop",
thumbnail: typeof doc.image === "object" && doc.image ? doc.image.url : null,
});
}
}
if (postsRes.ok) {
const data: { docs?: { id: number; slug: string; title: string; thumbnail: { url: string } | number | null }[] } = await postsRes.json();
for (const doc of data.docs ?? []) {
results.push({
type: "post",
id: `post-${doc.id}`,
title: doc.title,
href: `/blog/${doc.slug}`,
thumbnail: typeof doc.thumbnail === "object" && doc.thumbnail ? doc.thumbnail.url : null,
});
}
}
return NextResponse.json({ results });
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -183,9 +183,23 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
</Reveal>
{!productsLoading && (
// Outer card+sidebar split stays lg: (fixed 18rem delivery-status
// sidebar, L297 below — same fixed-width-block category as Tier C
// exceptions elsewhere), while the inner order-meta/items row
// already switches at sm: — intentional, not an inconsistent
// leftover: the inner row has no fixed-width sidebar to fight for
// room against, just two flexible columns.
//
// max-w-[36rem] between sm and lg (same fix/value as
// NewsletterModal.tsx's own dialog cap) — max-w-[56rem] (896px)
// doesn't actually constrain anything below that viewport width,
// so at real Tablet widths (640-1023px) this card stretched to
// fill the full page width instead of reading as a compact,
// centered card. Widens to the real 56rem cap only once lg:'s
// sidebar split kicks in and needs the room.
<Reveal
delay={0.05}
className="w-full max-w-[56rem] mx-auto bg-bg-base border border-border rounded-md overflow-hidden flex flex-col lg:flex-row mb-16"
className="w-full max-w-[36rem] lg:max-w-[56rem] mx-auto bg-bg-base border border-border rounded-md overflow-hidden flex flex-col lg:flex-row mb-16"
>
<div className="flex-1 p-6 md:p-8 flex flex-col sm:flex-row gap-8">
{/* Order meta */}
@@ -328,8 +342,8 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
identical height regardless of how many lines the quote wraps
to (a min-height alone lets whichever column has more content
stretch the row past what the other side visually fills). */}
<Reveal className="relative w-full flex items-stretch h-[14rem] md:h-[16rem] bg-bg-muted overflow-hidden">
<div className="relative w-full md:w-[45%] shrink-0">
<Reveal className="relative w-full flex items-stretch h-[14rem] sm:h-[16rem] bg-bg-muted overflow-hidden">
<div className="relative w-full sm:w-[45%] shrink-0">
{/* -inset-1, not inset-0 — this section fades in via Reveal's
y:28→0 transform; a plain inset-0 image can leave a
hairline gap at the top edge while that's still settling
@@ -343,10 +357,10 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
src="/bestellbestaetigung-testimonial-photo.jpg"
width={366}
height={126}
sizes="(min-width: 768px) 45vw, 100vw"
sizes="(min-width: 640px) 45vw, 100vw"
className="absolute -inset-1 w-[calc(100%+0.5rem)] h-[calc(100%+0.5rem)] object-cover"
/>
<div className="hidden md:block absolute inset-y-0 right-0 w-40 bg-gradient-to-l from-bg-muted to-transparent" />
<div className="hidden sm:block absolute inset-y-0 right-0 w-40 bg-gradient-to-l from-bg-muted to-transparent" />
</div>
<div className="flex-1 flex flex-col justify-center gap-3 px-8 md:px-16">
<p
@@ -9,7 +9,7 @@ import { mapPayloadPost, type PayloadPostDetail, type PostDetail } from "../../.
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
// Live-previewable subset of the blog detail page: title/category/readTime/
// Live-previewable subset of the blog detail page: title/categories/readTime/
// excerpt/byline, the thumbnail, and the RichText body — the fields an
// editor actually watches update while typing. The author bio card,
// "Weiterlesen" card, and Footer stay static in page.tsx: they either
@@ -27,7 +27,7 @@ export function LivePostContent({ initialPost }: { initialPost: PostDetail }) {
<>
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{post.category}</span>
<span>{post.categories.join(", ")}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
+7 -3
View File
@@ -7,8 +7,9 @@ import { Reveal } from "../../components/Reveal";
import { Footer } from "../../components/Footer";
import { RichText } from "../../components/RichText";
import { LivePostContent } from "./components/LivePostContent";
import { getBlogPosts, getPostBySlug } from "../../lib/payload";
import { getBlogPosts, getPostBySlug, getCompanySettings } from "../../lib/payload";
import { formatDate } from "../../lib/format";
import { buildArticleSchema } from "../../lib/structuredData";
export async function generateMetadata({
params,
@@ -61,9 +62,12 @@ export default async function BlogDetailPage({
// showing a fake/duplicate card when this is the only post.
const otherPosts = await getBlogPosts(4);
const nextPost = otherPosts.find((p) => p.slug !== post.slug) ?? null;
const seller = await getCompanySettings();
const articleSchema = buildArticleSchema(post, seller);
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }} />
<main className="flex flex-col flex-1 bg-bg-base">
{isPreview ? (
<LivePostContent initialPost={post} />
@@ -71,7 +75,7 @@ export default async function BlogDetailPage({
<>
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{post.category}</span>
<span>{post.categories.join(", ")}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
@@ -217,7 +221,7 @@ export default async function BlogDetailPage({
</div>
<div className="flex flex-col justify-center gap-2 p-6 min-w-0">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{nextPost.category}</span>
<span>{nextPost.categories.join(", ")}</span>
<span></span>
<span>{nextPost.readTime} Min</span>
</div>
+97 -14
View File
@@ -4,7 +4,7 @@ import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "../components/Reveal";
import { Newsletter } from "../components/Newsletter";
import { Footer } from "../components/Footer";
import { getBlogPosts } from "../lib/payload";
import { getBlogPosts, getBlogFilterEnabled } from "../lib/payload";
import { formatDate } from "../lib/format";
export const metadata: Metadata = {
@@ -20,8 +20,33 @@ export const metadata: Metadata = {
},
};
export default async function BlogOverviewPage() {
const posts = await getBlogPosts(100);
// Every distinct category name in DOM order — Payload's `categories` field
// stores related docs, no separate slug on this side, but since this page
// already fetches every published post (limit 100, no pagination), a
// plain client-agnostic array filter is enough; no separate Payload query
// per category needed.
function distinctCategories(posts: { categories: string[] }[]): string[] {
const seen = new Set<string>();
for (const post of posts) for (const c of post.categories) seen.add(c);
return Array.from(seen);
}
function buildCategoryHref(active: string[], category: string): string {
const next = active.includes(category) ? active.filter((c) => c !== category) : [...active, category];
return next.length > 0 ? `/blog?categories=${next.map(encodeURIComponent).join(",")}` : "/blog";
}
export default async function BlogOverviewPage({
searchParams,
}: {
searchParams: Promise<{ categories?: string }>;
}) {
const [allPosts, blogFilterEnabled] = await Promise.all([getBlogPosts(100), getBlogFilterEnabled()]);
const { categories: categoriesParam } = await searchParams;
const activeCategories = blogFilterEnabled && categoriesParam ? categoriesParam.split(",").filter(Boolean) : [];
const allCategories = blogFilterEnabled ? distinctCategories(allPosts) : [];
const posts =
activeCategories.length === 0 ? allPosts : allPosts.filter((post) => post.categories.some((c) => activeCategories.includes(c)));
const [featured, ...rest] = posts;
return (
@@ -30,8 +55,8 @@ export default async function BlogOverviewPage() {
{/* Header — copy left, photo bleeds to the viewport edge on the
right with a left-edge fade into bg-base, matching Figma's
hero-fade-left/hero-fade-bottom overlays (node 4577:330). */}
<Reveal className="relative flex flex-col md:flex-row items-center w-full min-h-[20rem] md:min-h-[27.5rem] border-b border-border overflow-hidden">
<div className="relative z-10 flex flex-col gap-4 items-start px-[var(--layout-padding-x)] py-10 md:py-0 w-full md:w-auto md:max-w-[26rem]">
<Reveal className="relative flex flex-col sm:flex-row items-center w-full min-h-[20rem] sm:min-h-[27.5rem] border-b border-border overflow-hidden">
<div className="relative z-10 flex flex-col gap-4 items-start px-[var(--layout-padding-x)] py-10 sm:py-0 w-full sm:w-auto sm:max-w-[26rem]">
<p
className="font-semibold text-display text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
@@ -42,13 +67,71 @@ export default async function BlogOverviewPage() {
Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.
</p>
</div>
<div className="relative w-full md:absolute md:inset-y-0 md:right-0 md:w-[68%] h-56 md:h-full">
<Image alt="" src="/hero.jpg" fill sizes="(min-width: 768px) 68vw, 100vw" className="object-cover" />
<div className="hidden md:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-base to-transparent" />
<div className="relative w-full sm:absolute sm:inset-y-0 sm:right-0 sm:w-[68%] h-56 sm:h-full">
<Image alt="" src="/hero.png" fill sizes="(min-width: 640px) 68vw, 100vw" className="object-cover object-top" />
<div className="hidden sm:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-base to-transparent" />
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-bg-base to-transparent" />
</div>
</Reveal>
{/* Plain div, not <Reveal> — this bar sits right at/just past the
hero's bottom edge, exactly the "already near the initial
viewport top" position Reveal.tsx's own comment documents as a
whileInView(margin:"-80px") trap: the shrunk viewport can
permanently miss triggering "entered view" for an element
that's already visible without any further scroll, since
`once: true` never gets a second chance. The Hero brand dot
hit the identical bug and switched to a plain animate — this
filter bar doesn't need a scroll-reveal animation at all, so
it's simplest to just not wrap it in Reveal in the first place. */}
{/* relative z-20 — the featured-post card right below pulls itself
up by -mt-8 with its own z-10 to overlap the *hero's* bottom
edge (its original, intended design). Inserting this filter
bar between the hero and that card meant the same -mt-8 pull
now overlapped THIS bar instead, and the card's higher/equal
stacking rendered on top of it, visually hiding the chips
behind the card. z-20 keeps this bar above that overlap
regardless. */}
{allCategories.length > 1 && (
<div className="relative z-20 flex flex-wrap gap-2 w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] pt-6">
{allCategories.map((category) => {
const active = activeCategories.includes(category);
return (
<Link
key={category}
href={buildCategoryHref(activeCategories, category)}
// bg-bg-muted, not bg-bg-base — border-border (#e5e0d8)
// on bg-base (#f8f5f1) is a ~2% lightness difference,
// nearly invisible as a pill outline; a filled muted
// background makes the chip read as a discrete control
// regardless of the border's own low contrast.
className={`inline-flex items-center px-3 py-1.5 rounded-full text-body-sm font-semibold whitespace-nowrap border transition-colors ${
active
? "bg-brand border-brand text-text-primary"
: "bg-bg-muted border-border text-text-muted hover:border-brand hover:text-brand"
}`}
>
{category}
</Link>
);
})}
{activeCategories.length > 0 && (
<Link
href="/blog"
className="inline-flex items-center px-3 py-1.5 text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors"
>
Zurücksetzen
</Link>
)}
</div>
)}
{posts.length === 0 && (
<p className="text-body text-text-muted w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] pt-10">
Keine Beiträge in dieser Kategorie gefunden.
</p>
)}
{featured && (
// -mt-8, not pt-10 — pulls the card up to slightly overlap the
// hero section's bottom edge instead of sitting flush below it.
@@ -56,16 +139,16 @@ export default async function BlogOverviewPage() {
<Reveal delay={0.1} className="relative z-10 w-full px-[var(--layout-padding-x)] -mt-8 pb-4">
<Link
href={`/blog/${featured.slug}`}
className="group grid grid-cols-1 md:grid-cols-2 w-full max-w-[80rem] mx-auto rounded-md overflow-hidden bg-bg-muted transition-transform duration-300 hover:-translate-y-1"
className="group grid grid-cols-1 sm:grid-cols-2 w-full max-w-[80rem] mx-auto rounded-md overflow-hidden bg-bg-muted transition-transform duration-300 hover:-translate-y-1"
>
<div className="relative w-full aspect-video md:aspect-auto md:h-full bg-bg-muted">
<div className="relative w-full aspect-video sm:aspect-auto sm:h-full bg-bg-muted">
{featured.thumbnail && (
<Image alt="" src={featured.thumbnail} fill sizes="(min-width: 768px) 40rem, 100vw" className="object-cover" />
<Image alt="" src={featured.thumbnail} fill sizes="(min-width: 640px) 40rem, 100vw" className="object-cover" />
)}
</div>
<div className="flex flex-col justify-center gap-3 p-8 md:p-12 min-w-0">
<div className="flex flex-col justify-center gap-3 p-8 sm:p-12 min-w-0">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{featured.category}</span>
<span>{featured.categories.join(", ")}</span>
<span></span>
<span>{featured.readTime} Min</span>
<span></span>
@@ -106,7 +189,7 @@ export default async function BlogOverviewPage() {
</div>
<div className="flex flex-col gap-2 min-w-0 flex-1">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{post.category}</span>
<span>{post.categories.join(", ")}</span>
<span></span>
<span>{post.readTime} Min</span>
<span></span>
+61 -34
View File
@@ -7,7 +7,7 @@ import Image from "next/image";
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { useDiscount, applyDiscount, clearDiscount } from "../../lib/discount";
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate, cartHasShippableItem } from "../../lib/cartTotals";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import { formatPrice, discountPercent } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
@@ -77,7 +77,7 @@ export function CartContent({
const subtotal = computeSubtotal(items);
const shipping =
items.length === 0 || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
items.length === 0 || !cartHasShippableItem(items) || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
? 0
: shippingCost;
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
@@ -170,15 +170,21 @@ export function CartContent({
) : (
<>
<div className="pt-2 px-[var(--layout-padding-x)] w-full">
<FreeShippingBanner subtotal={subtotal} threshold={freeShippingThreshold} />
{/* threshold forced to null (banner just doesn't render, see its
own early return) whenever nothing in the cart actually
triggers shipping — a "€X bis kostenlosem Versand" nudge
makes no sense for a cart that was never going to be charged
shipping in the first place. */}
<FreeShippingBanner subtotal={subtotal} threshold={cartHasShippableItem(items) ? freeShippingThreshold : null} />
</div>
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-start pb-10 pt-4 px-[var(--layout-padding-x)] w-full">
{/* Cart card — lg:-only split from the sidebar (same "wide content
next to sidebar" shape as the Hero's image/text split, see
figma-to-nextjs skill Gotcha #5: Figma's 830px card alone
already exceeds the 768px Tablet floor, so md: would never
have had room for a real 2-column layout at Tablet widths
anyway). */}
already exceeds even the site's 640px structural floor, so
sm: would never have had room for a real 2-column layout at
Tablet widths anyway — a deliberate exception to the site-wide
sm: consolidation, not a leftover of it). */}
<Reveal className="w-full lg:flex-1 flex flex-col gap-6 items-start bg-bg-base border border-border rounded-md p-6 md:p-8">
{items.map(({ entry, product }, i) => {
const discount = discountPercent(product.price, product.compareAtPrice);
@@ -391,31 +397,40 @@ export function CartContent({
)}
<div className="flex flex-col gap-0.5 w-full">
<div className="flex items-center w-full">
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
Versand
<button
type="button"
onClick={() => setVersandOpen(true)}
className="text-text-muted hover:text-brand transition-colors"
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
>
<span aria-hidden></span>
</button>
</span>
<span className="flex-1" />
<span className="text-body-sm text-text-primary">
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
</span>
</div>
<p className="text-label text-text-muted">
{shipping === 0 && freeShippingThreshold !== null
? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands`
: "innerhalb Deutschlands"}
</p>
<p className="text-label text-text-muted">
Lieferzeit {shippingSettings.totalDays.min}{shippingSettings.totalDays.max} Werktage
</p>
{/* Hidden entirely (not just "Kostenlos") when nothing in
the cart actually triggers shipping at all — that's a
different state from hitting the free-shipping
threshold, which is still a real promotional message
worth showing. */}
{cartHasShippableItem(items) && (
<>
<div className="flex items-center w-full">
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
Versand
<button
type="button"
onClick={() => setVersandOpen(true)}
className="text-text-muted hover:text-brand transition-colors"
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
>
<span aria-hidden></span>
</button>
</span>
<span className="flex-1" />
<span className="text-body-sm text-text-primary">
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
</span>
</div>
<p className="text-label text-text-muted">
{shipping === 0 && freeShippingThreshold !== null
? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands`
: "innerhalb Deutschlands"}
</p>
<p className="text-label text-text-muted">
Lieferzeit {shippingSettings.totalDays.min}{shippingSettings.totalDays.max} Werktage
</p>
</>
)}
</div>
<div className="h-px bg-border w-full" />
@@ -456,10 +471,22 @@ export function CartContent({
</div>
{/* Title only — /checkout renders the same CartTrustBadges
docs with description too, see CheckoutContent.tsx. */}
<div className="flex flex-col gap-4 items-start w-full">
docs with description too, see CheckoutContent.tsx.
Side by side between sm (640px) and lg (1024px) — this
sidebar is full page width through that whole range (it
only becomes a narrow fixed-width column at lg:, see this
file's own lg:w-[20.625rem] above), plenty of room for 3
short titles in a row; stacked at true mobile and again
once the sidebar narrows at lg:. sm:w-auto with no
flex-grow (was sm:flex-1) so each badge only takes its own
content width instead of stretching to fill the full card
width; sm:justify-center centers that shrink-to-fit row so
the leftover space splits evenly left/right instead of
collecting on the right (the default with items packed
from the start edge). */}
<div className="flex flex-col sm:flex-row sm:flex-wrap lg:flex-col gap-4 sm:gap-x-8 sm:gap-y-4 lg:gap-4 items-start sm:justify-center lg:justify-start w-full">
{trustBadges.map((b) => (
<div key={b.id} className="flex gap-3 items-center w-full">
<div key={b.id} className="flex gap-3 items-center w-full sm:w-auto lg:w-full">
<Image alt="" src={b.icon} width={22} height={22} className="size-[1.375rem] shrink-0 object-contain" />
<span className="flex-1 text-body-sm text-text-primary">{b.title}</span>
</div>
+5 -5
View File
@@ -123,7 +123,7 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
(opacity: 0) — invisible. Not worth chasing a fix for a
scroll-reveal nicety on a list that mutates; a static grid
renders correctly with no animation risk. */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
<div className="grid grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
{displayProducts.map((product, i) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
@@ -134,7 +134,7 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
<div
key={product.id}
className={
"group md:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1 " +
"group sm:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1 " +
// Center the row when there are fewer than 3 cards to show
// (e.g. only 1 active product left once the others are
// already in the cart) — only the first card needs an
@@ -142,9 +142,9 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
// it. 3-card case keeps the default left-to-right flow.
(i === 0
? displayProducts.length === 1
? "md:col-start-5"
? "sm:col-start-5"
: displayProducts.length === 2
? "md:col-start-3"
? "sm:col-start-3"
: ""
: "")
}
@@ -154,7 +154,7 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
src={product.image}
alt={product.name}
fill
sizes="(min-width: 768px) 320px, 100vw"
sizes="(min-width: 640px) 320px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{/* Same top-left pill pattern as ProductGrid.tsx/
@@ -0,0 +1,112 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { autocompleteDhlAddress, type DhlAddressSuggestion } from "../../lib/shippingDhl";
// Wraps a plain street-address input with a DHL DataFactory suggestion
// dropdown. Completely invisible/inert when the tenant doesn't have
// autocomplete activated (dhl-settings.autocompleteEnabled off) — the proxy
// route just returns an empty suggestions array in that case (see
// app/api/checkout/autocomplete-address/route.ts), so this degrades to a
// plain text input with no dropdown ever appearing, same "no half-built UI
// when a feature is off" convention as WishlistButton/searchEnabled.
//
// Styling duplicates FormField's classes (defined locally in
// CheckoutContent.tsx, not exported) rather than importing it, to keep this
// component usable on its own.
export function AddressAutocomplete({
label,
name,
value,
onChange,
onBlur,
onSelectSuggestion,
error,
placeholder,
autoComplete,
required,
wrapperClassName = "flex-1 min-w-0",
}: {
label: string;
name?: string;
value: string;
onChange: (value: string) => void;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
onSelectSuggestion: (suggestion: DhlAddressSuggestion) => void;
error?: string;
placeholder?: string;
autoComplete?: string;
required?: boolean;
wrapperClassName?: string;
}) {
const [suggestions, setSuggestions] = useState<DhlAddressSuggestion[]>([]);
const [open, setOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (value.trim().length < 3) {
setSuggestions([]);
return;
}
debounceRef.current = setTimeout(async () => {
const res = await fetch(`/api/checkout/autocomplete-address?query=${encodeURIComponent(value)}`);
const data = await res.json().catch(() => ({ ok: false, suggestions: [] }));
setSuggestions(data.ok ? data.suggestions : []);
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return (
<label className={`relative flex flex-col gap-2 items-start ${wrapperClassName}`}>
<span className="text-label text-text-muted">{label}</span>
<input
name={name}
type="text"
value={value}
onChange={(e) => {
onChange(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
// Delayed so a click on a suggestion below (which itself fires a
// blur first) still registers before the dropdown unmounts.
onBlur={(e) => {
setTimeout(() => setOpen(false), 150);
onBlur?.(e);
}}
placeholder={placeholder}
autoComplete={autoComplete}
required={required}
aria-invalid={error ? true : undefined}
className={`w-full border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors ${
error ? "border-red-600" : "border-border"
}`}
/>
{error && <span className="text-label text-red-600">{error}</span>}
{open && suggestions.length > 0 && (
<ul className="absolute top-full left-0 right-0 z-10 mt-1 max-h-60 overflow-y-auto rounded-sm border border-border bg-background shadow-lg">
{suggestions.map((s, i) => (
<li key={i}>
<button
type="button"
onClick={() => {
onChange(`${s.street}${s.houseNumber ? ` ${s.houseNumber}` : ""}`);
onSelectSuggestion(s);
setOpen(false);
}}
className="w-full text-left px-4 py-2 text-body-sm text-text-primary hover:bg-brand/10 transition-colors"
>
{s.street}
{s.houseNumber ? ` ${s.houseNumber}` : ""}, {s.zip} {s.city}
</button>
</li>
))}
</ul>
)}
</label>
);
}
+204 -82
View File
@@ -7,11 +7,13 @@ import { useRouter } from "next/navigation";
import { useCart, clearCart, mergeServerCartIntoLocal } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { useDiscount, clearDiscount } from "../../lib/discount";
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate, cartHasShippableItem } from "../../lib/cartTotals";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import { formatPrice } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { CustomSelect } from "../../components/CustomSelect";
import { VersandModal } from "../../components/VersandModal";
import { AddressAutocomplete } from "./AddressAutocomplete";
import { VatBreakdown } from "../../components/VatBreakdown";
import { CheckoutSteps } from "../../components/CheckoutSteps";
import { ORDER_KEY, PENDING_ORDER_KEY, type OrderSnapshot } from "../../lib/order";
@@ -243,19 +245,32 @@ export function CheckoutContent({
const [zip, setZip] = useState(savedProfile?.zip ?? "");
const [city, setCity] = useState(savedProfile?.city ?? "");
const [country, setCountry] = useState(savedProfile?.country ?? "Deutschland");
// Optional package destination distinct from the billing address above
// no savedProfile fallback (a customer's saved profile has only ever had
// one address), just an empty draft-only section.
const [hasDifferentShippingAddress, setHasDifferentShippingAddress] = useState(false);
const [shippingFirstName, setShippingFirstName] = useState("");
const [shippingLastName, setShippingLastName] = useState("");
const [shippingDeliveryMethod, setShippingDeliveryMethod] = useState<"address" | "packstation">("address");
const [shippingStreet, setShippingStreet] = useState("");
const [shippingPackstationNumber, setShippingPackstationNumber] = useState("");
const [shippingPostNumber, setShippingPostNumber] = useState("");
const [shippingZip, setShippingZip] = useState("");
const [shippingCity, setShippingCity] = useState("");
const [shippingCountry, setShippingCountry] = useState("Deutschland");
// Optional package destination distinct from the billing address above.
// Seeded from savedProfile's own "Lieferadresse" tab (Customers.ts),
// same precedence as Card 1's billing fields above: draft (if any)
// overwrites this in the hydration effect below, savedProfile is only
// the pre-hydration/SSR-safe fallback. Unlike Card 1, savedProfile *can*
// supply these now (see Customers.ts's "Lieferadresse" tab) — this used
// to always start empty since the saved profile had only one address.
const [hasDifferentShippingAddress, setHasDifferentShippingAddress] = useState(savedProfile?.hasDifferentShippingAddress ?? false);
const [shippingFirstName, setShippingFirstName] = useState(savedProfile?.shippingFirstName ?? "");
const [shippingLastName, setShippingLastName] = useState(savedProfile?.shippingLastName ?? "");
const [shippingDeliveryMethod, setShippingDeliveryMethod] = useState<"address" | "packstation">(
savedProfile?.shippingDeliveryMethod ?? "address",
);
const [shippingStreet, setShippingStreet] = useState(savedProfile?.shippingStreet ?? "");
const [shippingPackstationNumber, setShippingPackstationNumber] = useState(savedProfile?.shippingPackstationNumber ?? "");
const [shippingPostNumber, setShippingPostNumber] = useState(savedProfile?.shippingPostNumber ?? "");
const [shippingZip, setShippingZip] = useState(savedProfile?.shippingZip ?? "");
const [shippingCity, setShippingCity] = useState(savedProfile?.shippingCity ?? "");
const [shippingCountry, setShippingCountry] = useState(savedProfile?.shippingCountry ?? "Deutschland");
// Optional, mirrors companyName above — no shipping-side vatId though,
// a VAT ID is a billing/invoice concept, not a shipping one. Contact
// email/phone have no billing-side equivalent at all: they're handed to
// the shipping carrier, not used for any customer communication.
const [shippingCompanyName, setShippingCompanyName] = useState(savedProfile?.shippingCompanyName ?? "");
const [shippingContactEmail, setShippingContactEmail] = useState(savedProfile?.shippingContactEmail ?? "");
const [shippingContactPhone, setShippingContactPhone] = useState(savedProfile?.shippingContactPhone ?? "");
const [newsletterOptIn, setNewsletterOptIn] = useState(false);
// Live VIES status for the USt-IdNr. field — only meaningful once the
// goods' destination (shipping override country when set, billing
@@ -265,6 +280,7 @@ export function CheckoutContent({
// this exact same VIES check server-side at submit time regardless —
// this state is a preview, never the source of truth.
const [vatIdViesStatus, setVatIdViesStatus] = useState<"idle" | "checking" | "valid" | "invalid" | "unavailable">("idle");
const [dhlPostNumberStatus, setDhlPostNumberStatus] = useState<"idle" | "checking" | "valid" | "invalid" | "unavailable">("idle");
// Flips true only after the hydration effect's setState calls have
// actually landed in a render — gates the write-back effect below so it
// never fires with the pre-hydration defaults first and briefly
@@ -301,6 +317,9 @@ export function CheckoutContent({
if (draft.shippingZip) setShippingZip(draft.shippingZip);
if (draft.shippingCity) setShippingCity(draft.shippingCity);
if (draft.shippingCountry) setShippingCountry(draft.shippingCountry);
if (draft.shippingCompanyName) setShippingCompanyName(draft.shippingCompanyName);
if (draft.shippingContactEmail) setShippingContactEmail(draft.shippingContactEmail);
if (draft.shippingContactPhone) setShippingContactPhone(draft.shippingContactPhone);
if (typeof draft.newsletterOptIn === "boolean") setNewsletterOptIn(draft.newsletterOptIn);
if (draft.shippingMethodId != null) setShippingMethodId(draft.shippingMethodId);
if (draft.paymentMethodId != null) setPaymentMethodId(draft.paymentMethodId);
@@ -331,6 +350,9 @@ export function CheckoutContent({
shippingZip,
shippingCity,
shippingCountry,
shippingCompanyName,
shippingContactEmail,
shippingContactPhone,
newsletterOptIn,
shippingMethodId,
paymentMethodId,
@@ -356,6 +378,9 @@ export function CheckoutContent({
shippingZip,
shippingCity,
shippingCountry,
shippingCompanyName,
shippingContactEmail,
shippingContactPhone,
newsletterOptIn,
shippingMethodId,
paymentMethodId,
@@ -372,7 +397,7 @@ export function CheckoutContent({
selectedShipping?.freeShippingThreshold !== null &&
selectedShipping?.freeShippingThreshold !== undefined &&
subtotal >= selectedShipping.freeShippingThreshold;
const shipping = items.length === 0 || freeShipping ? 0 : selectedShipping?.price ?? 0;
const shipping = items.length === 0 || !cartHasShippableItem(items) || freeShipping ? 0 : selectedShipping?.price ?? 0;
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
const taxBreakdown = computeTaxBreakdown(
items.map(({ entry, product }) => ({
@@ -449,6 +474,42 @@ export function CheckoutContent({
}
}
// Live-checks the Packstation Postnummer against DHL, mirroring
// handleVatIdBlur's shape. `ok: false` from the proxy route covers both
// "DHL unreachable" and "this tenant doesn't have Postnummer-validation
// activated" (dhl-settings.postnummerEnabled off) — the latter is the
// common case for shops without a DHL integration, so it silently falls
// back to idle (format-only, already checked above) rather than showing
// an alarming "currently unavailable" message for a feature that was
// simply never turned on.
async function handlePostNumberBlur(e: React.FocusEvent<HTMLInputElement>) {
const input = e.target;
const value = input.value;
const formatError = validatePostNumber(value);
setFieldError("shippingPostNumber", formatError, input);
if (!value.trim() || formatError || !shippingFirstName.trim() || !shippingLastName.trim()) {
setDhlPostNumberStatus("idle");
return;
}
setDhlPostNumberStatus("checking");
try {
const res = await fetch("/api/checkout/validate-dhl-postnumber", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ postNumber: value, firstName: shippingFirstName, lastName: shippingLastName }),
});
const data = await res.json();
if (!data.ok) {
setDhlPostNumberStatus("idle");
return;
}
setDhlPostNumberStatus(data.valid ? "valid" : "invalid");
if (!data.valid) input.focus();
} catch {
setDhlPostNumberStatus("idle");
}
}
// Logs into an existing account inline, without leaving /checkout —
// router.refresh() re-runs the page's Server Component, which re-reads
// the now-set session cookie and passes the resolved customerEmail back
@@ -558,6 +619,9 @@ export function CheckoutContent({
shippingZip: hasDifferentShippingAddress ? shippingZip : undefined,
shippingCity: hasDifferentShippingAddress ? shippingCity : undefined,
shippingCountry: hasDifferentShippingAddress ? shippingCountry : undefined,
shippingCompanyName: hasDifferentShippingAddress ? shippingCompanyName || undefined : undefined,
shippingContactEmail: hasDifferentShippingAddress ? shippingContactEmail || undefined : undefined,
shippingContactPhone: hasDifferentShippingAddress ? shippingContactPhone || undefined : undefined,
newsletterOptIn,
};
@@ -911,7 +975,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"
@@ -951,13 +1015,16 @@ export function CheckoutContent({
Rechnungsadresse (an invoice needs a real postal address).
Packstation is only ever offered below, in the optional
"Abweichende Lieferadresse" section's own Lieferart toggle. */}
<FormField
<AddressAutocomplete
label="Straße und Hausnummer"
name="street"
type="text"
value={street}
onChange={(e) => setStreet(e.target.value)}
onChange={setStreet}
onBlur={(e) => setFieldError("street", validateRequired("Straße und Hausnummer", e.target.value), e.target)}
onSelectSuggestion={(s) => {
setZip(s.zip);
setCity(s.city);
}}
error={fieldErrors.street}
placeholder="Musterstraße 1"
autoComplete="street-address"
@@ -996,17 +1063,19 @@ export function CheckoutContent({
</div>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Land</span>
<select
name="country"
{/* CustomSelect, not a native <select> — consistent with the
filter dropdowns elsewhere (native options popups can't be
styled at all). includeAllOption={false}: every country is
a real, selectable choice, there's no "clear" concept
here — "country" is always genuinely set to something. */}
<CustomSelect
label="Land wählen"
includeAllOption={false}
fullWidth
options={shippingCountries.map((c) => ({ value: c.name, label: c.name }))}
value={country}
onChange={(e) => setCountry(e.target.value)}
required
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
>
{shippingCountries.map((c) => (
<option key={c.name}>{c.name}</option>
))}
</select>
onChange={setCountry}
/>
</label>
<div className="h-px bg-border w-full" />
@@ -1048,6 +1117,15 @@ export function CheckoutContent({
required
/>
</div>
<FormField
label="Firma (optional)"
type="text"
value={shippingCompanyName}
onChange={(e) => setShippingCompanyName(e.target.value)}
placeholder="Muster GmbH"
autoComplete="off"
wrapperClassName="w-full"
/>
<div className="w-full sm:w-[calc(50%-0.5rem)] flex flex-col gap-2 items-start">
<span className="text-label text-text-muted">Lieferart</span>
<div className="flex w-full rounded-sm border border-border overflow-hidden">
@@ -1078,12 +1156,15 @@ export function CheckoutContent({
</div>
</div>
{shippingDeliveryMethod === "address" ? (
<FormField
<AddressAutocomplete
label="Straße und Hausnummer"
type="text"
value={shippingStreet}
onChange={(e) => setShippingStreet(e.target.value)}
onChange={setShippingStreet}
onBlur={(e) => setFieldError("shippingStreet", validateRequired("Straße und Hausnummer", e.target.value), e.target)}
onSelectSuggestion={(s) => {
setShippingZip(s.zip);
setShippingCity(s.city);
}}
error={fieldErrors.shippingStreet}
placeholder="Musterstraße 1"
autoComplete="off"
@@ -1107,21 +1188,34 @@ export function CheckoutContent({
title="Packstationsnummer muss aus 1 bis 3 Ziffern bestehen (1999)."
required
/>
<FormField
label="Postnummer"
type="text"
value={shippingPostNumber}
onChange={(e) => setShippingPostNumber(e.target.value)}
onBlur={(e) => setFieldError("shippingPostNumber", validatePostNumber(e.target.value), e.target)}
error={fieldErrors.shippingPostNumber}
inputMode="numeric"
placeholder="1234567890"
autoComplete="off"
pattern="\d{6,10}"
maxLength={10}
title="Postnummer muss aus 6 bis 10 Ziffern bestehen."
required
/>
<div className="flex-1 min-w-0 flex flex-col gap-1">
<FormField
label="Postnummer"
type="text"
value={shippingPostNumber}
onChange={(e) => setShippingPostNumber(e.target.value)}
onBlur={handlePostNumberBlur}
error={fieldErrors.shippingPostNumber}
inputMode="numeric"
placeholder="1234567890"
autoComplete="off"
pattern="\d{6,10}"
maxLength={10}
title="Postnummer muss aus 6 bis 10 Ziffern bestehen."
required
wrapperClassName="w-full"
/>
{/* Silent when idle — covers both "not yet checked" and
"this shop has no DHL Postnummer-validation active",
same reasoning as handlePostNumberBlur's own comment. */}
<p className="text-label text-text-muted min-h-[1.05rem]">
{dhlPostNumberStatus === "checking" && "Postnummer wird bei DHL geprüft…"}
{dhlPostNumberStatus === "valid" && <span className="text-success"> Postnummer bestätigt</span>}
{dhlPostNumberStatus === "invalid" && (
<span className="text-red-600">DHL konnte Postnummer/Name-Kombination nicht bestätigen.</span>
)}
</p>
</div>
</div>
)}
<div className="flex flex-col sm:flex-row gap-4 w-full">
@@ -1154,17 +1248,39 @@ export function CheckoutContent({
</div>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Land</span>
<select
<CustomSelect
label="Land wählen"
includeAllOption={false}
fullWidth
options={shippingCountries.map((c) => ({ value: c.name, label: c.name }))}
value={shippingCountry}
onChange={(e) => setShippingCountry(e.target.value)}
required
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
>
{shippingCountries.map((c) => (
<option key={c.name}>{c.name}</option>
))}
</select>
onChange={setShippingCountry}
/>
</label>
{/* Both optional and independent — handed to the shipping
carrier, not used for any customer communication (that
stays the account email above). Useful when the
recipient at this address isn't reachable via the
account holder's own email/phone. */}
<div className="flex flex-col sm:flex-row gap-4 w-full">
<FormField
label="Kontakt-E-Mail (optional)"
type="email"
value={shippingContactEmail}
onChange={(e) => setShippingContactEmail(e.target.value)}
placeholder="empfang@beispiel.de"
autoComplete="off"
/>
<FormField
label="Telefonnummer (optional)"
type="tel"
value={shippingContactPhone}
onChange={(e) => setShippingContactPhone(e.target.value)}
placeholder="+49 30 123456"
autoComplete="off"
/>
</div>
<p className="text-label text-text-muted">Werden nur dem Versanddienstleister übergeben, z. B. für Lieferbenachrichtigungen.</p>
</div>
)}
@@ -1337,33 +1453,39 @@ export function CheckoutContent({
</div>
)}
<div className="flex flex-col gap-0.5 w-full">
<div className="flex items-center w-full">
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
Versand
<button
type="button"
onClick={() => setVersandOpen(true)}
className="text-text-muted hover:text-brand transition-colors"
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
>
<span aria-hidden></span>
</button>
</span>
<span className="flex-1" />
<span className="text-body-sm text-text-primary">
{displayShipping === 0 ? "Kostenlos" : formatPrice(displayShipping)}
</span>
{/* Hidden entirely (not just "Kostenlos") when nothing in the
cart actually triggers shipping at all — a different state
from hitting the free-shipping threshold, which is still a
real promotional message worth showing. */}
{cartHasShippableItem(items) && (
<div className="flex flex-col gap-0.5 w-full">
<div className="flex items-center w-full">
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
Versand
<button
type="button"
onClick={() => setVersandOpen(true)}
className="text-text-muted hover:text-brand transition-colors"
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
>
<span aria-hidden></span>
</button>
</span>
<span className="flex-1" />
<span className="text-body-sm text-text-primary">
{displayShipping === 0 ? "Kostenlos" : formatPrice(displayShipping)}
</span>
</div>
<p className="text-label text-text-muted">
{shipping === 0 && selectedShipping?.freeShippingThreshold != null
? `ab ${formatPrice(selectedShipping.freeShippingThreshold)} innerhalb Deutschlands`
: (selectedShipping?.description ?? "innerhalb Deutschlands")}
</p>
<p className="text-label text-text-muted">
Lieferzeit {shippingSettings.totalDays.min}{shippingSettings.totalDays.max} Werktage
</p>
</div>
<p className="text-label text-text-muted">
{shipping === 0 && selectedShipping?.freeShippingThreshold != null
? `ab ${formatPrice(selectedShipping.freeShippingThreshold)} innerhalb Deutschlands`
: (selectedShipping?.description ?? "innerhalb Deutschlands")}
</p>
<p className="text-label text-text-muted">
Lieferzeit {shippingSettings.totalDays.min}{shippingSettings.totalDays.max} Werktage
</p>
</div>
)}
<div className="h-px bg-border w-full" />
+37 -9
View File
@@ -24,25 +24,43 @@ type Props = {
testMode: boolean;
/** Only present in test mode — see api/checkout/route.ts's own comment. */
providerReference?: string;
/** Where /checkout/verarbeitung sends the customer once payment is
* confirmed — "checkout" (default) clears the cart/draft and lands on
* /bestellbestaetigung, exactly like today. "account" is used by the
* account order detail page's "Zahlungsart ändern" flow (an existing,
* already-confirmed order — nothing to clear, and /bestellbestaetigung
* would be the wrong destination): lands back on that same order's
* page instead. See VerarbeitungContent.tsx's own branching on this. */
returnContext?: "checkout" | "account";
};
// Rendered by CheckoutContent once /api/checkout returns
// `requiresPayment: true` (Kreditkarte/PayPal) — see
// spicy-leaping-pizza.md §3/§7. The order already exists in Payload at
// this point (status 'pending_payment'); this step only collects/confirms
// the actual payment, it doesn't create anything.
export function PaymentStep({ clientSecret, orderNumber, orderId, testMode, providerReference }: Props) {
// the actual payment, it doesn't create anything. Also reused as-is by
// the account order detail page's payment-method-switch flow (see
// returnContext above) — the Stripe collection UI itself is identical
// either way, only the post-payment destination differs.
export function PaymentStep({ clientSecret, orderNumber, orderId, testMode, providerReference, returnContext = "checkout" }: Props) {
if (testMode) {
return <TestPaymentButtons orderNumber={orderNumber} orderId={orderId} providerReference={providerReference ?? ""} />;
return (
<TestPaymentButtons
orderNumber={orderNumber}
orderId={orderId}
providerReference={providerReference ?? ""}
returnContext={returnContext}
/>
);
}
return (
<Elements stripe={getStripe()} options={{ clientSecret }}>
<StripePaymentForm orderNumber={orderNumber} />
<StripePaymentForm orderNumber={orderNumber} returnContext={returnContext} />
</Elements>
);
}
function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
function StripePaymentForm({ orderNumber, returnContext }: { orderNumber: string; returnContext: "checkout" | "account" }) {
const stripe = useStripe();
const elements = useElements();
const [submitting, setSubmitting] = useState(false);
@@ -62,7 +80,7 @@ function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
const { error: confirmError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}`,
return_url: `${window.location.origin}/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}&context=${returnContext}`,
},
});
// Only reached for immediate client-side failures (e.g. invalid card
@@ -80,7 +98,7 @@ function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
<button
type="submit"
disabled={!stripe || submitting}
className="rounded-full bg-brand-primary px-6 py-3 text-white font-semibold disabled:opacity-50"
className="rounded-full bg-brand px-6 py-3 text-text-primary font-semibold hover:bg-brand-hover active:scale-[0.97] transition-all disabled:opacity-50"
>
{submitting ? "Wird bearbeitet…" : "Jetzt bezahlen"}
</button>
@@ -88,7 +106,17 @@ function StripePaymentForm({ orderNumber }: { orderNumber: string }) {
);
}
function TestPaymentButtons({ orderNumber, orderId, providerReference }: { orderNumber: string; orderId: number; providerReference: string }) {
function TestPaymentButtons({
orderNumber,
orderId,
providerReference,
returnContext,
}: {
orderNumber: string;
orderId: number;
providerReference: string;
returnContext: "checkout" | "account";
}) {
const router = useRouter();
const [submitting, setSubmitting] = useState<"paid" | "failed" | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -108,7 +136,7 @@ function TestPaymentButtons({ orderNumber, orderId, providerReference }: { order
setSubmitting(null);
return;
}
router.push(`/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}`);
router.push(`/checkout/verarbeitung?orderNumber=${encodeURIComponent(orderNumber)}&context=${returnContext}`);
} catch {
setError("Testzahlung konnte nicht ausgeführt werden.");
setSubmitting(null);
@@ -24,6 +24,11 @@ export function VerarbeitungContent() {
const router = useRouter();
const searchParams = useSearchParams();
const orderNumber = searchParams.get("orderNumber");
// "account" — the payment-method-switch flow on an existing order's
// account page (see PaymentStep.tsx's own returnContext comment).
// Defaults to "checkout" for a bare/missing param, same as before this
// branch existed.
const isAccountContext = searchParams.get("context") === "account";
const [state, setState] = useState<"polling" | "timeout" | "failed" | "error">(orderNumber ? "polling" : "error");
const startedAt = useRef<number | null>(null);
@@ -42,6 +47,15 @@ export function VerarbeitungContent() {
return;
}
if (data.paymentStatus === "paid") {
if (isAccountContext) {
// Nothing to clear — this order was already placed (as
// Überweisung) and confirmed long before this switch, there's
// no cart/discount/draft snapshot involved. Land back on the
// same order instead of /bestellbestaetigung, which would
// read as a brand-new purchase.
router.push(`/konto/bestellungen/${encodeURIComponent(orderNumber!)}`);
return;
}
try {
const pending = window.sessionStorage.getItem(PENDING_ORDER_KEY);
if (pending) {
@@ -114,13 +128,15 @@ export function VerarbeitungContent() {
Zahlung fehlgeschlagen
</p>
<p className="text-body text-text-muted max-w-md">
Deine Zahlung konnte nicht abgeschlossen werden. Dein Warenkorb ist noch vorhanden du kannst es gerne erneut versuchen.
{isAccountContext
? "Die Zahlung konnte nicht abgeschlossen werden. Deine Bestellung bleibt unverändert — du kannst es jederzeit erneut versuchen."
: "Deine Zahlung konnte nicht abgeschlossen werden. Dein Warenkorb ist noch vorhanden — du kannst es gerne erneut versuchen."}
</p>
<Link
href="/checkout"
href={isAccountContext ? `/konto/bestellungen/${encodeURIComponent(orderNumber ?? "")}` : "/checkout"}
className="flex items-center gap-2 px-7 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
>
Zurück zum Checkout
{isAccountContext ? "Zurück zur Bestellung" : "Zurück zum Checkout"}
</Link>
</>
)}
@@ -133,10 +149,10 @@ export function VerarbeitungContent() {
Falls die Zahlung erfolgreich war, erhältst du in Kürze eine Bestätigungs-E-Mail. Andernfalls kannst du es erneut versuchen.
</p>
<Link
href="/checkout"
href={isAccountContext ? `/konto/bestellungen/${encodeURIComponent(orderNumber ?? "")}` : "/checkout"}
className="flex items-center gap-2 px-7 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
>
Zurück zum Checkout
{isAccountContext ? "Zurück zur Bestellung" : "Zurück zum Checkout"}
</Link>
</>
)}
+3
View File
@@ -21,11 +21,14 @@ const FALLBACK: CompanySettings = {
sellerCity: "",
sellerCountry: "",
sellerEmail: "",
emailFromName: null,
emailFromAddress: null,
vatId: "",
taxRatePercent: 19,
kleinunternehmer: false,
iban: null,
bic: null,
bankName: null,
};
// Entered exclusively via CompanySettings.ts's admin.livePreview.url (a
+33 -24
View File
@@ -3,19 +3,20 @@ import { Reveal } from "./Reveal";
export function About() {
return (
<section id="ueber-bjoern" className="bg-bg-dark flex flex-col md:flex-row md:items-stretch w-full">
<section id="ueber-bjoern" className="bg-bg-dark flex flex-col sm:flex-row sm:items-stretch w-full">
{/* Text content — relative + z-10 so it renders above the overlapping
photo at lg+. Comes first in DOM at every breakpoint (no reorder
here — unlike Hero, there's no conversion CTA at stake).
md:flex-[1.4_0_0] lg:flex-[1_0_0] — at Tablet the text column got
the narrower 1:1.4 share meant for Desktop's overlap layout,
leaving it too cramped for the fixed-width statement + quote/bio
row. Widened at Tablet (text gets the bigger share, image the
smaller one, no overlap yet) and reverted to the original ratio
from lg: up, where the overlap trick actually needs the image to
have more room. */}
<Reveal className="flex flex-col gap-4 justify-center px-[var(--layout-padding-x)] py-8 md:flex-[1.4_0_0] lg:flex-[1_0_0] min-w-0 relative z-10">
The 1:1.4 text:image ratio (text gets the narrower share) applies
from sm: up unchanged — the photo needs the room, the text
column doesn't need to be wide (the inner quote/bio row stays
stacked at Tablet regardless, see below, so a wider text column
doesn't even help it go inline; it just steals space the photo
should have — reported 2026-07-29). Only the -ml-48 overlap
itself (see the photo below) is gated to lg:, since that trick
needs more absolute room than Tablet has to not look cramped. */}
<Reveal className="flex flex-col gap-4 justify-center px-[var(--layout-padding-x)] py-8 sm:flex-[1_0_0] min-w-0 relative z-10">
{/* Large serif statement — width-constrained as per design */}
<p
@@ -26,11 +27,13 @@ export function About() {
</p>
{/* Quote row: script quote / divider / author bio — side-by-side
from lg: (was md:) — even with the text column's wider Tablet
share above, quote + divider + the whitespace-nowrap bio ("Gründer
von einfach-produktiv.") together still needed more room than
Tablet's ~384px column has. Stacked with a horizontal divider
through the whole Tablet range instead, side-by-side (vertical
only from lg: (1024px), a deliberate exception to the site's
sm: (640px) structural consolidation: quote + divider + the
whitespace-nowrap bio ("Gründer von einfach produktiv.")
together need more room than the sm:-anchored text column has
through the whole 640-1023px range, independent of the outer
text/image ratio above. Stacked with a horizontal divider
through that whole range instead, side-by-side (vertical
divider) only once there's real room at lg:. */}
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6 lg:gap-0 w-full">
@@ -74,19 +77,20 @@ export function About() {
<p>Björn.</p>
<p>Führungskraft.</p>
<p>Familienmensch.</p>
<p>Gründer von einfach-produktiv.</p>
<p>Gründer von einfach produktiv.</p>
</div>
</div>
</Reveal>
{/* Author photo — overlaps the text column via -ml-48 from lg+ only
(that overlap trick has nothing to blend into once stacked, and
at Tablet it would eat back into the extra width the text column
above just gained); plain full-width photo below the text on
Mobile, plain side-by-side (no overlap) at Tablet. */}
{/* Author photo — gets the wider 1.4 share from sm: up already (see
the text column's own comment above); plain, no overlap at
Tablet. Overlaps the text column via -ml-48 only from lg+, where
there's real absolute room for the photo to bleed under it
without looking cramped. Plain full-width photo below the text
on true Mobile. */}
<Reveal
className="relative overflow-hidden w-full md:flex-[1_0_0] lg:flex-[1.4_0_0] lg:-ml-48"
className="relative overflow-hidden w-full sm:flex-[1.4_0_0] lg:-ml-48"
style={{ minHeight: "14rem" }}
delay={0.15}
>
@@ -94,11 +98,16 @@ export function About() {
alt="Björn"
src="/about-author.jpg"
fill
sizes="(min-width: 1024px) 58vw, (min-width: 768px) 42vw, 100vw"
sizes="(min-width: 640px) 58vw, 100vw"
className="object-cover object-center pointer-events-none"
/>
{/* Left gradient: wide enough to cover the text-column overlap — lg+ only */}
<div className="hidden lg:block absolute inset-y-0 left-0 w-72 bg-gradient-to-r from-bg-dark to-transparent pointer-events-none" />
{/* Left gradient — from sm: up, not just lg:. Even without the
-ml-48 overlap at Tablet, the photo sits directly against the
dark text column with no transition, reading as a hard vertical
seam (reported 2026-07-29). A narrower fade (w-16) softens just
that seam at Tablet; widens to w-72 at lg: to also cover the
deeper -ml-48 overlap once that kicks in. */}
<div className="hidden sm:block absolute inset-y-0 left-0 w-16 lg:w-72 bg-gradient-to-r from-bg-dark to-transparent pointer-events-none" />
</Reveal>
</section>
+19 -15
View File
@@ -31,23 +31,23 @@ export async function Blog() {
{/* Posts grid */}
<RevealGroup className="flex flex-col gap-10 items-start px-[var(--layout-padding-x)] w-full">
{/* Featured post — image on top on Mobile, side-by-side from md+ */}
<RevealItem className="w-full border border-border rounded-xl overflow-hidden grid grid-cols-1 md:grid-cols-12 transition-transform duration-300 hover:-translate-y-1">
<div className="relative w-full aspect-video md:aspect-auto md:col-span-7 md:h-[220px] bg-bg-muted">
{/* Featured post — image on top on Mobile, side-by-side from sm+ */}
<RevealItem className="w-full border border-border rounded-xl overflow-hidden grid grid-cols-1 sm:grid-cols-12 transition-transform duration-300 hover:-translate-y-1">
<div className="relative w-full aspect-video sm:aspect-auto sm:col-span-7 sm:h-[220px] bg-bg-muted">
{featured.thumbnail && (
<Image
alt=""
src={featured.thumbnail}
fill
sizes="(min-width: 768px) 58vw, 100vw"
sizes="(min-width: 640px) 58vw, 100vw"
className="object-cover pointer-events-none"
/>
)}
</div>
<div className="flex flex-col justify-between gap-4 py-4 px-4 md:pr-4 md:pl-4 md:col-span-5 min-w-0">
<div className="flex flex-col justify-between gap-4 py-4 px-4 sm:pr-4 sm:pl-4 sm:col-span-5 min-w-0">
<div className="flex flex-col gap-3">
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
<span>{featured.category}</span>
<span>{featured.categories.join(", ")}</span>
<span></span>
<span>{featured.readTime} Min</span>
</div>
@@ -58,7 +58,7 @@ export async function Blog() {
>
{featured.title}
</p>
<p className="font-normal text-body leading-6 line-clamp-2 md:line-clamp-none">
<p className="font-normal text-body leading-6 line-clamp-2 sm:line-clamp-none">
{featured.excerpt}
</p>
</div>
@@ -75,28 +75,32 @@ export async function Blog() {
</div>
</RevealItem>
{/* Secondary posts — image on top on Mobile, side-by-side from md+ */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full">
{/* Secondary posts — image on top through lg (1024px), side-by-side
only from lg+. These sit 2-up (sm:grid-cols-2 below) well
before lg, so each card's own available width through the
640-1023px tablet range is too narrow for image+text side by
side — was sm:grid-cols-5, squeezing the text column. */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 w-full">
{secondary.map((post) => (
<RevealItem
key={post.id}
className="border border-border rounded-xl overflow-hidden grid grid-cols-1 md:grid-cols-5 transition-transform duration-300 hover:-translate-y-1"
className="border border-border rounded-xl overflow-hidden grid grid-cols-1 lg:grid-cols-5 transition-transform duration-300 hover:-translate-y-1"
>
<div className="relative w-full aspect-video md:aspect-auto md:col-span-2 md:h-[230px] bg-bg-muted">
<div className="relative w-full aspect-video lg:aspect-auto lg:col-span-2 lg:h-[230px] bg-bg-muted">
{post.thumbnail && (
<Image
alt=""
src={post.thumbnail}
fill
sizes="(min-width: 768px) 29vw, 100vw"
sizes="(min-width: 1024px) 29vw, 100vw"
className="object-cover pointer-events-none"
/>
)}
</div>
<div className="flex flex-col justify-between gap-4 py-3 px-4 md:col-span-3 min-w-0 text-text-primary">
<div className="flex flex-col justify-between gap-4 py-3 px-4 lg:col-span-3 min-w-0 text-text-primary">
<div className="flex flex-col gap-3">
<div className="flex gap-2 items-center font-semibold text-text-muted text-body uppercase whitespace-nowrap">
<span>{post.category}</span>
<span>{post.categories.join(", ")}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
@@ -107,7 +111,7 @@ export async function Blog() {
>
{post.title}
</p>
<p className="font-normal text-body leading-6 line-clamp-1 md:line-clamp-none">
<p className="font-normal text-body leading-6 line-clamp-1 lg:line-clamp-none">
{post.excerpt}
</p>
</div>
+155
View File
@@ -0,0 +1,155 @@
"use client";
import { useEffect, useRef, useState } from "react";
type Option = { value: string; label: string };
// A fully custom-styled dropdown — a native <select>'s trigger box can be
// styled, but its open options popup is rendered by the browser/OS itself
// and can't be reached with CSS at all (wrong font size, wrong colors, no
// brand styling whatsoever). This renders both the trigger and the
// options panel as plain HTML we control end to end. Originally built for
// /konto/bestellungen's filters, promoted to a shared component so
// checkout's country selects can use the same look (moved here 2026-07-30).
export function CustomSelect({
label,
options,
value,
onChange,
includeAllOption = true,
fullWidth = false,
}: {
/** Screen-reader label — also the trigger's placeholder text when
* `includeAllOption` is true and nothing is selected. */
label: string;
options: Option[];
value: string;
onChange: (value: string) => void;
/** true (default): prepends a `{value: "", label}` "clear/show all"
* pseudo-option — the filter-dropdown use case (Order/blog/etc.
* filters), where "nothing selected" is a real, meaningful state.
* false: no pseudo-option, every real option is selectable and one is
* always genuinely selected — the plain-select-replacement use case
* (e.g. checkout's country picker), where there's no "clear" concept. */
includeAllOption?: boolean;
/** false (default): trigger shrinks to its content width from sm: up —
* right for a row of compact filter dropdowns. true: trigger always
* stays full width of its container — right for a form-field
* replacement (e.g. checkout's country picker, alongside other w-full
* inputs). */
fullWidth?: boolean;
}) {
const [open, setOpen] = useState(false);
const [highlighted, setHighlighted] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const allOptions: Option[] = includeAllOption ? [{ value: "", label }, ...options] : options;
const selectedIndex = Math.max(
0,
allOptions.findIndex((o) => o.value === value),
);
const selectedLabel = allOptions[selectedIndex]?.label ?? label;
useEffect(() => {
if (!open) return;
setHighlighted(selectedIndex);
function onClickOutside(e: MouseEvent) {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
if (!open) return;
listRef.current?.querySelector<HTMLElement>(`[data-index="${highlighted}"]`)?.scrollIntoView({ block: "nearest" });
}, [open, highlighted]);
// Keyboard/focus-driven close: Tabbing (or programmatically moving
// focus) away from the trigger+list entirely used to leave the panel
// open forever — the mousedown-outside listener above only ever reacts
// to a mouse click, not focus leaving via Tab. `relatedTarget` is where
// focus is headed; null on some browsers when it lands outside the
// document/on a non-focusable element, which should also close.
function onBlur(e: React.FocusEvent) {
if (!rootRef.current?.contains(e.relatedTarget as Node)) setOpen(false);
}
function select(index: number) {
onChange(allOptions[index].value);
setOpen(false);
}
function onKeyDown(e: React.KeyboardEvent) {
if (!open) {
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
}
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlighted((i) => Math.min(i + 1, allOptions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlighted((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
select(highlighted);
} else if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
}
}
return (
<div ref={rootRef} onBlur={onBlur} className={`relative w-full ${fullWidth ? "" : "sm:w-auto"}`}>
<button
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={label}
onClick={() => setOpen((v) => !v)}
onKeyDown={onKeyDown}
className={`flex items-center justify-between gap-2 w-full ${fullWidth ? "" : "sm:w-auto min-w-[10rem]"} border rounded-sm ${
fullWidth ? "px-4 py-3" : "px-3 py-2"
} text-body-sm transition-colors outline-none ${
value ? "border-brand text-text-primary" : "border-border text-text-muted"
} hover:border-brand focus-visible:border-brand`}
>
<span className="truncate">{selectedLabel}</span>
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" className={`shrink-0 transition-transform ${open ? "rotate-180" : ""}`}>
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open && (
<ul
ref={listRef}
role="listbox"
aria-label={label}
className="absolute z-20 mt-1 w-full sm:min-w-[12rem] max-h-64 overflow-y-auto bg-bg-base border border-border rounded-sm shadow-lg py-1"
>
{allOptions.map((o, i) => (
<li
key={o.value || "__all__"}
data-index={i}
role="option"
aria-selected={i === selectedIndex}
onMouseEnter={() => setHighlighted(i)}
onClick={() => select(i)}
className={`px-3 py-2 text-body-sm cursor-pointer transition-colors ${
i === selectedIndex ? "font-semibold text-brand" : "text-text-primary"
} ${i === highlighted ? "bg-bg-muted" : ""}`}
>
{o.label}
</li>
))}
</ul>
)}
</div>
);
}
+1 -1
View File
@@ -52,7 +52,7 @@ export function Divider() {
</div>
<div className="flex items-center gap-3 sm:gap-8 shrink-0">
<Word>Entlastung</Word>
<Word>Leichtigkeit</Word>
{/* Sparkle icon — sizes now fluid (--divider-sparkle-*) to match
the text-h2 words next to it, previously hard rem values that
+9 -4
View File
@@ -19,10 +19,15 @@ export function Footer() {
<div className="flex flex-col items-center w-full max-w-[1280px] py-8 md:py-4">
{/* Three groups: logo | @handle | links — stacked + centered below
lg: (was md:). Logo + handle + 5 legal links all side by side
with justify-between read too cramped on Tablet — stacked
through that range instead, side by side again once there's
real room at lg:. */}
lg:. Reverted here from an earlier sm: rename (2026-07-29's
breakpoint migration): this row isn't the "grid arrives before
the fluid floor" bug the sm: consolidation targets — logo +
handle + 5 nowrap legal links (all shrink-0) simply don't fit
in one row below ~1024px regardless of fluid scaling (measured:
~746px combined natural width at a 666px viewport, causing a
real horizontal page overflow, not just visual cramping).
Same fixed-content-doesn't-fit category as Cart/Checkout/
SectionTOC, kept at lg: for the same reason. */}
<div className="flex flex-col lg:flex-row items-center lg:justify-between gap-6 lg:gap-0 px-8 md:px-16 w-full">
{/* Logo: "einfach produktiv" white + "." gold */}
+68 -48
View File
@@ -11,7 +11,7 @@ function HeroImage() {
alt=""
fill
priority
sizes="(min-width: 768px) 58vw, 100vw"
sizes="(min-width: 640px) 58vw, 100vw"
className="object-cover"
style={{
WebkitMaskImage:
@@ -28,44 +28,56 @@ function HeroImage() {
export function Hero() {
return (
<section className="bg-bg-base w-full overflow-hidden">
{/* Structural breakpoint is md: (768px) for the GRID only — the text
column stays ~283-320px wide through the whole 768-1023px Tablet
{/* Structural breakpoint is sm: (640px, moved down from the old md:
768px so the grid arrives exactly where the fluid token floor
also sits — see fluid.ts/globals.css) for the GRID only — the
text column stays narrow through the whole 640-1023px Tablet
range regardless. Below, every piece of *content* inside the text
column (heading/subtitle/CTA/social-proof) keeps its smaller,
fixed-below-lg: sizing all the way through Tablet too, not just
true Mobile — reusing the full fluid-token sizes at md: (as a
first pass 2026-07-24 briefly did) put the original ~19-44px
fluid floors right back in that narrow column, recreating the
exact 3-line-wrap problem the old `lg:` structural exception
existed to avoid. Splitting "grid at md:" from "full-size content
at lg:" gets both: Tablet shows the real 5/7 grid, but with
content sized for its column's actual width, not the column
width `lg:` was designed for. */}
<div className="flex flex-col md:grid md:grid-cols-12 md:items-center gap-8 md:gap-[var(--layout-grid-gap)] pt-10 md:pt-0">
column (heading/subtitle/CTA/social-proof) deliberately keeps its
smaller, fixed-below-lg: sizing all the way through Tablet too,
not just true Mobile — reusing the full fluid-token sizes that
early put the original ~19-44px fluid floors right back in that
narrow column, recreating the exact 3-line-wrap problem the old
`lg:` structural exception existed to avoid. This is a narrower,
intentional exception to the site-wide sm: consolidation — the
column's real width at sm: hasn't been visually verified yet
(no browser in this environment), so full-size content stays
gated behind lg: as a fast-follow rather than a guess. Splitting
"grid at sm:" from "full-size content at lg:" gets both: Tablet
shows the real 5/7 grid, but with content sized for its column's
actual width, not the column width `lg:` was designed for. */}
<div className="flex flex-col sm:grid sm:grid-cols-12 sm:items-center gap-8 sm:gap-[var(--layout-grid-gap)] pt-10 sm:pt-0">
{/* Text content — first in DOM/visual order at every breakpoint so
the CTA stays above the fold on Mobile (deliberate exception to
the "keep DOM order" default, see Hero decision in the plan).
Reveal fires ~immediately since Hero is already in the initial
viewport — this doubles as the page's entrance animation. */}
<Reveal className="order-1 md:order-none md:col-span-5 flex flex-col gap-7 items-start pl-[var(--layout-padding-x)] pr-10 md:pr-0">
viewport — this doubles as the page's entrance animation.
Centered below sm: (true Mobile) — the CTA/social-proof below
were already centered there, left-aligned heading/subheading
above them read inconsistent (reported 2026-07-29). Left-
aligned again from sm: up, matching the rest of the column. */}
<Reveal className="order-1 sm:order-none sm:col-span-5 flex flex-col gap-7 items-center sm:items-start pl-[var(--layout-padding-x)] pr-10 sm:pr-0">
{/* Heading — smaller fixed-ish size below lg: (text-h1, still a
real paired font-size+line-height token, not an arbitrary
value) — text-display's own 44px floor wraps very heavily in
a ~283-320px Tablet column (even a single word can approach
that width). Forced break after "darf" only in the sm-md
tablet range (natural wrap there landed awkwardly); removed
at true mobile widths (below sm:) 2026-07-24 — narrower
still, natural wrap reads fine there, and the forced break
made "darf" the whole first line. Full text-display only
from lg: up, where the column has real room again. */}
that width). The old forced break after "darf" only existed
for a "wide single-column, not yet grid" band (640-767px
under the old md: 768px grid switch) that no longer exists
now the grid itself starts at sm: (640px) — below sm: the
stack is narrower than that old band ever was, where natural
wrap already reads fine (confirmed 2026-07-24), so the break
is removed rather than re-anchored to a new range. Full
text-display only from lg: up, where the column has real
room again. */}
<p
className="font-semibold leading-[0] shrink-0 text-[0px] text-text-primary"
className="font-semibold leading-[0] shrink-0 text-[0px] text-text-primary text-center sm:text-left"
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
Verlier&apos; dich nicht im mehr, verankere was zählt
</span>
{/* Brand's signature orange dot (also in the logo/footer) —
bouncy pop-in once the heading scrolls into view, timed to
@@ -83,14 +95,16 @@ export function Hero() {
{/* Subheading — smaller fixed size below lg:, text-h-emphasis's
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
<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] text-center sm:text-left [word-break:break-word] not-italic">
Ich helfe dir, zwischen Job, Familie und eigenen Projekten nicht unterzugehen.
</p>
{/* CTA */}
{/* CTA — centered below sm: (reads better against the centered
image/banner above it on true Mobile), left-aligned with the
rest of the text column again from sm: up. */}
<Link
href="/challenge"
className="flex gap-4 items-center justify-center overflow-clip px-6 py-3 rounded-sm shrink-0 max-w-full bg-brand hover:brightness-95 active:scale-[0.97] transition-all"
href="/lebensuhr"
className="flex gap-4 items-center justify-center overflow-clip px-6 py-3 rounded-sm shrink-0 max-w-full bg-brand hover:brightness-95 active:scale-[0.97] transition-all self-center sm:self-auto"
>
{/* Letting this wrap to two lines below lg: (tried 2026-07-24)
put the icon beside a two-line text block, which read as
@@ -100,7 +114,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
@@ -112,8 +126,14 @@ export function Hero() {
{/* Social proof — always avatars-then-text on two lines below
lg: (not just when it happens to overflow), single row again
from lg: up where the real column width fits it fine. */}
<div className="flex flex-col lg:flex-row gap-3 items-center justify-center overflow-clip shrink-0 w-full">
from lg: up where the real column width fits it fine.
Centered on true Mobile (<640px, matches the CTA above it,
see self-center there); left-aligned in the 640-1023px
Tablet band instead, matching the left-aligned heading/
subheading — plain centered there read as floating/
disconnected from that column (reported 2026-07-29); back to
a centered row at lg:. */}
<div className="flex flex-col lg:flex-row gap-3 items-center sm:items-start lg:items-center justify-center sm:justify-start lg:justify-center overflow-clip shrink-0 w-full">
{/* Avatars — gap 2px, not overlapping */}
<div className="flex gap-[0.125rem] items-center shrink-0">
{["/avatar-1.jpg", "/avatar-2.jpg", "/avatar-3.jpg"].map((src, i) => (
@@ -126,36 +146,36 @@ export function Hero() {
</div>
))}
</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>
<p className="flex-[1_0_0] font-normal leading-[1.5rem] text-text-primary text-body text-center sm:text-left [word-break:break-word]">
1.120+ Menschen vertrauen <span className="whitespace-nowrap">einfach produktiv.</span>
</p>
</div>
</Reveal>
{/* Image — bleeds to the true edge at every breakpoint (never
padded). Below md: (stacked layout) the full 887:583 aspect
ratio at 100vw would make the image ~600-900px tall and
dominate the page, so height is capped and object-cover crops
it into a supporting banner instead; at md:+ (grid, image only
58% width) the full aspect ratio looks right again, so the cap
is lifted. No shadow: a plain box-shadow reads as a hard
rectangular edge against the existing corner/right/bottom
mask-gradient fade below, which looked worse than no shadow at
all — tried and reverted. */}
{/* No Reveal (fade-in-on-scroll) below md: — whileInView's -80px
padded). Below sm: (stacked layout, moved down from the old
md: 768px) the full 887:583 aspect ratio at 100vw would make
the image ~600-900px tall and dominate the page, so height is
capped and object-cover crops it into a supporting banner
instead; at sm:+ (grid, image only 58% width) the full aspect
ratio looks right again, so the cap is lifted. No shadow: a
plain box-shadow reads as a hard rectangular edge against the
existing corner/right/bottom mask-gradient fade below, which
looked worse than no shadow at all — tried and reverted. */}
{/* No Reveal (fade-in-on-scroll) below sm: — whileInView's -80px
viewport margin means the image doesn't fade in until scrolled
that much further into view; on a short mobile viewport this
image sits right at the initial fold, so it stayed at
opacity:0 (a white gap, matching the section's own bg-bg-base)
above the fold until the user scrolled (reported 2026-07-24).
Plain, always-visible image below md: instead; Reveal's fade
kept from md: up, where the image is beside the text with
Plain, always-visible image below sm: instead; Reveal's fade
kept from sm: up, where the image is beside the text with
plenty of room and this was never an issue. */}
<div className="order-2 md:hidden relative w-full aspect-[887/583] max-h-[16rem]">
<div className="order-2 sm:hidden relative w-full aspect-[887/583] max-h-[16rem]">
<HeroImage />
</div>
<Reveal
className="hidden md:block md:col-span-7 relative w-full aspect-[887/583]"
className="hidden sm:block sm:col-span-7 relative w-full aspect-[887/583]"
delay={0.15}
>
<HeroImage />
+85 -9
View File
@@ -6,6 +6,8 @@ import Image from "next/image";
import { usePathname } from "next/navigation";
import { AnimatePresence, motion } from "motion/react";
import { useCartCount } from "../lib/cart";
import { useWishlist } from "../lib/useWishlist";
import { SearchButton } from "./SearchOverlay";
import { AUTH_CHANGED_EVENT } from "../lib/auth";
import { NewsletterModal } from "./NewsletterModal";
import { useCartFly } from "./CartFly";
@@ -58,7 +60,7 @@ function getNavLinks(singleActiveProduct: boolean) {
// mark "Werkzeuge" active in the nav for the same reason — confirmed by
// page-weekly-impulses's own breadcrumb ("Startseite Werkzeuge
// Impulse & Tipps") and active-underline position, same as page-todo-karten's.
const WERKZEUGE_ROUTES = ["/todo-cards", "/challenge", "/newsletter"];
const WERKZEUGE_ROUTES = ["/todo-cards", "/lebensuhr", "/newsletter"];
function isNavLinkActive(href: string, pathname: string, activeSection: string): boolean {
if (href === "#werkzeuge") {
@@ -131,6 +133,40 @@ function AccountLink() {
);
}
// Wishlist icon + count badge — only rendered by the caller when
// `wishlistEnabled` (CompanySettings), and even then hidden below `sm:`.
// Account+Cart are the only always-visible icons on true mobile (see the
// trailing-controls group's own comment: gap-2 there was already tuned
// specifically for exactly 2 icons) — a 3rd icon squeezed in at the
// smallest phone widths risks the exact nav-overflow class of bug
// documented in the figma-to-nextjs skill (computed hamburger/icon-row
// thresholds, not assumed ones). sm+ has real room to spare.
function WishlistLink() {
const { count } = useWishlist();
return (
<Link
href="/konto/merkliste"
aria-label={count > 0 ? `Merkliste, ${count} Artikel` : "Merkliste"}
className="relative hidden sm:flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
>
<svg viewBox="0 0 20 18" className="h-6 w-6 text-text-primary" fill="none" aria-hidden="true">
<path
d="M10 17S1 11.5 1 5.8C1 2.6 3.4 1 5.8 1c1.6 0 3.2.9 4.2 2.4C11 1.9 12.6 1 14.2 1 16.6 1 19 2.6 19 5.8 19 11.5 10 17 10 17Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
{count > 0 && (
<span className="absolute top-0 right-0 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-brand px-1 text-[0.6875rem] font-bold leading-none text-white">
{count > 99 ? "99+" : count}
</span>
)}
</Link>
);
}
// Cart icon + count badge — traced from the Figma Navbar/Default component's
// btn-cart (icon-cart 32x30 + cart-count-badge, node 4849:24). Visible at
// every breakpoint tier (unlike the nav links / CTA buttons, which move into
@@ -210,7 +246,15 @@ function CartLink() {
);
}
export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }) {
export function Navbar({
singleActiveProduct,
wishlistEnabled,
searchEnabled,
}: {
singleActiveProduct: boolean;
wishlistEnabled: boolean;
searchEnabled: boolean;
}) {
const pathname = usePathname();
const [scrolled, setScrolled] = useState(false);
const [activeSection, setActiveSection] = useState("");
@@ -383,6 +427,16 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
// header itself to be a flexible column that could grow for it — it's
// now a fixed-position sibling instead (see that panel's own comment
// on why), so this is back to a plain fixed-height bar.
//
// onClick here closes the drawer on any click that lands on the bar
// itself (logo/cart/account/CTAs already call closeMobile() from
// their own onClick, so this mainly covers clicking empty space in
// the bar) — safe as a catch-all specifically because the panel is
// a sibling, not a descendant, so a click inside the open drawer
// never bubbles up to this handler. The hamburger's own onClick
// stops propagation so toggling it open doesn't immediately get
// undone by this same handler.
onClick={() => mobileOpen && setMobileOpen(false)}
className={`sticky top-0 z-50 w-full h-[6.25rem] transition-[background-color,backdrop-filter] duration-300 ${
scrolled || mobileOpen
? "bg-bg-base/80 backdrop-blur-md"
@@ -428,7 +482,17 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
Figma's Navbar-Mobile component set exactly, see the plan.
Matches the Figma NavLink component's hover: text never
changes color, an underline (brand, 2px x 40px) fades in on
hover and stays on for the active section. */}
hover and stays on for the active section.
Deliberate exception to the site-wide sm: (640px) structural
consolidation (see the 640px-breakpoint plan): this md:/lg:
3-tier scheme (hamburger-only <768, hamburger+inline-CTAs
768-1023, full-inline ≥1024) is untouched by that migration.
Horizontal nav-link overflow is a different failure mode than
the vertical grid/flex reflows the rest of the site has —
there's no fluid token that shrinks link text to make a full
inline nav fit at 640px, and nothing here was ever tied to
the fluid token scale the way Hero/About/etc. were. */}
<nav className="hidden lg:flex items-center gap-12">
{navLinks.map((link) => {
const isActive = isNavLinkActive(link.href, pathname, activeSection);
@@ -489,7 +553,9 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
built-in padding read as too much space on mobile, where
these two icons are the only always-visible controls. */}
<div className="flex items-center">
{searchEnabled && <SearchButton />}
<AccountLink />
{wishlistEnabled && <WishlistLink />}
<CartLink />
</div>
@@ -509,10 +575,10 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
Newsletter
</button>
<Link
href="/challenge"
href="/3x3-system"
className="px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary whitespace-nowrap hover:bg-brand-hover active:scale-[0.97] transition-all"
>
7-Tage-Challenge
Mein 3x3-System
</Link>
</div>
@@ -525,7 +591,10 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
aria-expanded={mobileOpen}
aria-controls="mobile-nav-panel"
aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"}
onClick={() => setMobileOpen((v) => !v)}
onClick={(e) => {
e.stopPropagation();
setMobileOpen((v) => !v);
}}
className="lg:hidden flex h-11 w-11 items-center justify-center shrink-0"
>
<span className="relative block h-4 w-6">
@@ -571,7 +640,14 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
className="lg:hidden fixed inset-0 z-40 bg-bg-base overflow-y-auto"
initial={{ clipPath: "circle(0vmax at 100% 0%)" }}
animate={{ clipPath: "circle(150vmax at 100% 0%)" }}
exit={{ clipPath: "circle(0vmax at 100% 0%)" }}
// Own (slower, gentler) transition for the exit — the shared
// ease-out curve above is front-loaded (fast start, slow
// finish), which reads great for the reveal but meant the
// close shrank most of the way almost immediately, then
// lingered on a barely-visible sliver — felt abrupt rather
// than smooth. easeInOut plus a longer duration spreads the
// shrink evenly instead.
exit={{ clipPath: "circle(0vmax at 100% 0%)", transition: { duration: 0.7, ease: "easeInOut" } }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
>
<div className="flex flex-col min-h-full pt-[6.25rem]">
@@ -658,11 +734,11 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
Newsletter
</button>
<Link
href="/challenge"
href="/3x3-system"
onClick={closeMobile}
className="md:hidden min-h-11 flex items-center justify-center px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
>
7-Tage-Challenge
Mein 3x3-System
</Link>
</motion.div>
</div>
+46 -26
View File
@@ -31,10 +31,10 @@ type NewsletterProps = {
* instead of a second near-duplicate component.
*/
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.",
title = "Kleine Impulse große Wirkung",
description = "Melde dich an und bekomme meine Sonntags-Impulse ab jetzt jede Woche direkt in dein Postfach: kurze Gedanken, praktische Impulse und kleine Anstöße für mehr Klarheit im Alltag.",
}: 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 (
@@ -43,17 +43,35 @@ export function Newsletter({
{/* Outer section padding — same fluid horizontal padding as all other sections */}
<div className="px-[var(--layout-padding-x)] w-full">
{/* Rounded card: cream bg, stacks below md */}
<Reveal className="bg-bg-muted flex flex-col md:flex-row gap-8 md:gap-12 items-center px-8 py-8 md:py-0 rounded-md w-full">
{/* max-w-[1280px] mx-auto — the consistent default width for this
card, matching /lebensuhr's own newsletter-style "Bottom CTA"
section (same 1280px cap Footer.tsx already uses) and every
other narrow-viewport-friendly section on the site. Previously
unbounded here, so on wide desktop viewports this card stretched
edge-to-edge while /lebensuhr's version stayed centered and
noticeably narrower — reported 2026-07-29. */}
{/* Rounded card: cream bg, stacks below sm (moved down from the old md:) */}
{/* py-8 at every breakpoint, not just below sm (was sm:py-0,
relying purely on items-center + the taller column's own
height to create top/bottom breathing room) — the form column
(input, checkbox+label, privacy note) can be as tall as or
taller than the copy column depending on viewport width, so
centering alone left no slack to distribute and the input/
"Keine Werbung" note sat flush against the card's top/bottom
edge. */}
<Reveal className="bg-bg-muted flex flex-col sm:flex-row gap-8 sm:gap-12 items-center px-8 py-8 rounded-md w-full max-w-[1280px] mx-auto">
{/* Left: copy — fixed width from md+ so the form always gets the remaining space.
Icon+text stacked (icon on top, centered) below md: — side by
side they squeezed the text into a ~164px column on a 375px
phone (icon width + gap eating most of the card's inner
width), wrapping awkwardly. Row layout with the icon beside
the text is fine again from md+, where the fixed copy-column
width leaves real room. */}
<div className="flex flex-col items-center gap-4 text-center w-full md:flex-row md:items-start md:gap-8 md:text-left md:w-[var(--newsletter-copy-width)] md:py-4 md:shrink-0">
{/* Left: copy — fixed width from sm+ so the form always gets the remaining space.
Icon+text stacked (icon on top, centered) below sm: — side by
side they squeezed the text into a narrow column on a phone
(icon width + gap eating most of the card's inner width),
wrapping awkwardly, and the same squeeze reappeared through
the whole tablet range (640-1023px) once the outer card's own
sm:flex-row already put this copy column next to the form —
row layout for icon+text is only comfortable once there's
real room, i.e. lg+. Left-aligned (not centered) from sm up —
only true mobile keeps the centered treatment. */}
<div className="flex flex-col items-center gap-4 text-center w-full sm:items-start sm:text-left lg:flex-row lg:gap-8 sm:w-[var(--newsletter-copy-width)] lg:py-4 lg:shrink-0">
{/* Decorative envelope icon, tilted -4° as per design.
w-[4rem], not w-16 — this project's --spacing-16 is a
@@ -89,22 +107,24 @@ export function Newsletter({
</div>
</div>
{/* 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">
{/* Right: form — takes remaining space, centered vertically from sm+ */}
<div className="flex w-full sm:flex-1 items-center sm: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">
{/* Input + submit button — stacked below lg: (was md:).
The card above already goes side-by-side at md: with a
fixed-width copy column (--newsletter-copy-width), which
only leaves ~200px for this form column at 768px — not
enough room for input+button side by side. Stacked
through the whole Tablet range instead, side by side
again once the form column has real room at lg:. */}
{/* Input + submit button — stacked below lg:, a deliberate
exception to the site's sm: (640px) structural
consolidation, not a leftover of it. The card above
already goes side-by-side at sm: with a fixed-width
copy column (--newsletter-copy-width), which only
leaves ~128px for this form column at 640px and
~197px at 768px — not enough room for input+button
side by side even at the new, lower floor. Stacked
through the whole 640-1023px range instead, side by
side again once the form column has real room (≈333px)
at lg:. */}
<div className="flex flex-col lg:flex-row gap-4 items-stretch w-full">
<input
ref={emailRef}
@@ -140,7 +160,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">
+55 -24
View File
@@ -10,7 +10,7 @@ const features = [
{
icon: "/icon-sparkle-wrapper.svg",
title: "7 Tage. Ein Fokus.",
desc: "Tägliche Impulse für mehr Klarheit und weniger Reibung.",
desc: "Wöchentliche Impulse für mehr Klarheit und weniger Reibung.",
},
{
icon: "/icon-checklist.png",
@@ -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/
@@ -144,26 +144,50 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.97 }}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[75rem] max-h-[90vh] overflow-y-auto"
// Capped narrower than the Desktop 75rem through the whole
// 640-1023px Tablet band — this dialog is a modal, not a page
// section, so at Tablet it should read as a compact centered
// card, not stretch to near-full-viewport-width once stacked
// single-column (see the flex-col/flex-row split below):
// full-bleed-width + a single stacked photo/text column made
// the photo huge and the text below it look lost/disconnected
// (reported 2026-07-29, after first trying a lg:-gated
// structural stack with no width cap). max-w-[75rem] only
// takes over once the 2-column split itself starts at lg:.
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[36rem] lg:max-w-[75rem] max-h-[90vh] overflow-y-auto"
>
{/* sticky, not absolute — the dialog itself is the scrolling
container (overflow-y-auto above), so an absolute-positioned
child scrolls away with the rest of the content instead of
staying pinned to the visible top-right corner (reported
2026-07-29). sticky top-6 keeps it fixed to the scrolled
viewport's top edge; ml-auto pushes it to the right within
the dialog's normal block flow (sticky positioning doesn't
use right-* the way absolute does); -mb-6 cancels its own
height (size-6 = 1.5rem) so it doesn't push the modal-top
content below it down — same visual overlap as the old
absolute positioning, just still visible after scrolling. */}
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
aria-label="Schließen"
className="absolute top-6 right-6 z-10 size-6 flex items-center justify-center active:scale-90 transition-transform"
className="sticky top-6 ml-auto mr-6 -mb-6 z-20 size-6 flex items-center justify-center active:scale-90 transition-transform"
>
<Image alt="" src="/icon-close.png" width={24} height={24} className="size-full object-contain" />
</button>
{/* modal-top: photo + copy/form, stacked below md */}
<div className="flex flex-col md:flex-row items-stretch border-b border-border">
<div className="relative w-full md:flex-1 aspect-[4/3] md:aspect-auto">
{/* modal-top: photo + copy/form, stacked below lg: — paired with
the dialog's own narrower max-w-[36rem] cap through Tablet
(see above), so the stacked photo stays a reasonably-sized
4:3 banner instead of blowing up to near-full-viewport-width. */}
<div className="flex flex-col lg:flex-row items-stretch border-b border-border">
<div className="relative w-full lg:flex-1 aspect-[4/3] lg:aspect-auto">
<Image
src="/newsletter-modal-photo.jpg"
alt="Notizbuch mit Kaffee und Stift"
fill
sizes="(min-width: 768px) 50vw, 100vw"
sizes="(min-width: 1024px) 50vw, 36rem"
className="object-cover"
/>
</div>
@@ -172,8 +196,8 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
{/* -scale-y-100 is required, not just -rotate-4 — the SVG
itself is authored upside-down (matches how Newsletter.tsx
uses this exact same asset); without it the icon renders
flipped. Hidden below md: — removed on mobile 2026-07-24. */}
<div className="hidden md:block w-16 h-14 -rotate-4 -scale-y-100">
flipped. Hidden below lg: — removed on mobile 2026-07-24. */}
<div className="hidden lg:block w-16 h-14 -rotate-4 -scale-y-100">
<Image alt="" src="/newsletter-icon.svg" width={64} height={56} className="w-full h-full" />
</div>
@@ -182,20 +206,18 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
className="font-semibold text-h-feature text-text-primary leading-[1.15]"
style={{ fontFamily: "var(--font-lora)" }}
>
Starte mit einer Woche voller Klarheit<span className="text-brand">.</span>
Kleine Impulse große Wirkung
</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 an und bekomme meine Sonntags-Impulse ab jetzt jede Woche direkt in dein Postfach: kurze Gedanken, praktische Impulse und kleine Anstöße für mehr Klarheit im Alltag.
</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">
<div className="flex flex-col gap-2 items-start w-full">
<input
ref={emailRef}
type="email"
@@ -209,9 +231,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 +251,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 +267,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>
@@ -258,7 +287,9 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
items-start + a fixed icon bounding box (icons have different
native proportions, e.g. the sparkle glyph isn't square) fixes
it without needing the flip trick. */}
<div className="flex flex-col md:flex-row items-start px-8 md:px-20 py-6 md:py-9 gap-8 md:gap-6">
{/* Same lg: exception as modal-top above, for the same narrower-
dialog-width-through-Tablet reason. */}
<div className="flex flex-col lg:flex-row items-start px-8 md:px-20 py-6 md:py-9 gap-8 lg:gap-6">
{features.map((f) => (
<div key={f.title} className="flex-1 flex gap-6 items-start w-full">
<div className="relative h-10 w-10 shrink-0 flex items-center justify-center">
+29 -8
View File
@@ -2,7 +2,8 @@ import Image from "next/image";
import Link from "next/link";
import { AddToCartButton } from "./AddToCartButton";
import { Reveal } from "./Reveal";
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
import { WishlistButton } from "./WishlistButton";
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled } from "../lib/payload";
import { formatPrice, discountPercent } from "../lib/format";
import { effectiveTaxRate } from "../lib/cartTotals";
@@ -23,11 +24,12 @@ import { effectiveTaxRate } from "../lib/cartTotals";
* see Products.ts), not duplicated here as hardcoded literals.
*/
export async function ProductSpotlight() {
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
const [product, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled] = await Promise.all([
getSpotlightProduct(),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
getWishlistEnabled(),
]);
if (!product) return null;
@@ -42,14 +44,22 @@ export async function ProductSpotlight() {
// id="spotlight" — the Navbar's "Shop" link becomes an anchor to this
// section instead of navigating to /shop whenever exactly 1 product is
// active (see Navbar.tsx/layout.tsx).
<section id="spotlight" className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
<Reveal className="max-w-[75rem] mx-auto rounded-md flex flex-col md:flex-row gap-8 md:gap-12 items-center p-6 md:p-10">
<div className="group relative w-full md:w-[23.75rem] md:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
<section id="spotlight" className="w-full bg-bg-base py-12 sm:py-16 px-[var(--layout-padding-x)]">
{/* Row layout only from lg (1024px) up — sm:flex-row used to kick in
at 640px, but a 380px-wide image + text squeezed into the rest of
a tablet-width viewport (768-1023px) read as cramped/too wide.
Tablet now stays stacked like mobile, but max-w-[32rem] between sm
and lg keeps that stacked card centered and narrower than the
tablet viewport instead of stretching to fill it — true mobile
(below sm) stays unconstrained since the viewport itself is
already narrow there. */}
<Reveal className="max-w-[75rem] sm:max-w-[32rem] lg:max-w-[75rem] mx-auto rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 sm:p-10">
<div className="group relative w-full lg:w-[23.75rem] lg:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
<Image
src={image}
alt={product.name}
fill
sizes="(min-width: 768px) 380px, 100vw"
sizes="(min-width: 1024px) 380px, (min-width: 640px) 512px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{fullyOutOfStock ? (
@@ -63,6 +73,9 @@ export async function ProductSpotlight() {
</span>
)
)}
{wishlistEnabled && product.spotlightShowWishlist && (
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
)}
</div>
<div className="flex flex-col gap-4 items-start flex-1 min-w-0 w-full">
@@ -86,10 +99,18 @@ export async function ProductSpotlight() {
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
</div>
<p className="text-label text-text-muted">{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}</p>
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
{kleinunternehmer
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
{!product.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
</div>
{/* Single product, no grid siblings to stay equal-height with
(unlike ProductGrid.tsx/RelatedProducts.tsx), so this can be
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import type { SearchResult } from "../api/search/route";
const DEBOUNCE_MS = 250;
export function SearchButton() {
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("keydown", onKeyDown);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = "";
};
}, [open]);
return (
<>
{/* hidden sm: — same reasoning as Navbar's WishlistLink: Account+Cart
are the only always-visible icons on true mobile, a 3rd icon
there risks the same computed nav-overflow class of bug the
figma-to-nextjs skill documents. sm+ has real room to spare. */}
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Suche öffnen"
className="hidden sm:flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
>
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-primary" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
<path d="M20 20L16.5 16.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
</button>
{open && <SearchOverlay onClose={() => setOpen(false)} />}
</>
);
}
function SearchOverlay({ onClose }: { onClose: () => void }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (query.trim().length < 2) {
setResults([]);
setLoading(false);
return;
}
setLoading(true);
debounceRef.current = setTimeout(async () => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query.trim())}`, { cache: "no-store" });
const data: { results?: SearchResult[] } = await res.json().catch(() => ({ results: [] }));
setResults(data.results ?? []);
setLoading(false);
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [query]);
const products = results.filter((r) => r.type === "product");
const posts = results.filter((r) => r.type === "post");
return (
<div className="fixed inset-0 z-[100] flex flex-col items-center bg-bg-base/95 backdrop-blur-sm pt-[15vh] px-[var(--layout-padding-x)]" onClick={onClose}>
<div className="w-full max-w-[36rem]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-3 border-b-2 border-border focus-within:border-brand transition-colors pb-3">
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-muted shrink-0" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.8" />
<path d="M20 20L16.5 16.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Produkte, Blogbeiträge…"
className="flex-1 min-w-0 bg-transparent outline-none text-h4 text-text-primary placeholder:text-text-muted"
/>
<button type="button" onClick={onClose} aria-label="Suche schließen" className="shrink-0 text-body-sm text-text-muted hover:text-brand transition-colors">
Esc
</button>
</div>
<div className="mt-6 flex flex-col gap-6 max-h-[55vh] overflow-y-auto">
{loading && <p className="text-body-sm text-text-muted">Suche</p>}
{!loading && query.trim().length >= 2 && results.length === 0 && (
<p className="text-body-sm text-text-muted">Keine Treffer für {query}.</p>
)}
{products.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-label font-bold text-text-muted uppercase tracking-wide">Produkte</p>
{products.map((r) => (
<SearchResultRow key={r.id} result={r} onClose={onClose} />
))}
</div>
)}
{posts.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-label font-bold text-text-muted uppercase tracking-wide">Blog</p>
{posts.map((r) => (
<SearchResultRow key={r.id} result={r} onClose={onClose} />
))}
</div>
)}
</div>
</div>
</div>
);
}
function SearchResultRow({ result, onClose }: { result: SearchResult; onClose: () => void }) {
return (
<Link
href={result.href}
onClick={onClose}
className="flex items-center gap-3 p-2 rounded-sm hover:bg-bg-muted transition-colors"
>
<div className="relative h-12 w-12 shrink-0 rounded-sm overflow-hidden bg-bg-muted">
{result.thumbnail && <Image alt="" src={result.thumbnail} fill sizes="48px" className="object-cover" />}
</div>
<span className="text-body text-text-primary">{result.title}</span>
</Link>
);
}
+3 -2
View File
@@ -61,8 +61,9 @@ function useActiveSection(sections: TOCSection[]) {
// lg:-only sidebar — same "wide fixed-width block next to content" shape
// as the cart's order-summary sidebar (see figma-to-nextjs skill Gotcha
// #5): a 360px TOC card plus a readable content column already exceeds
// the 768px Tablet floor, so md: wouldn't leave room for a real 2-column
// split at Tablet widths.
// even the site's 640px structural floor, so sm: wouldn't leave room for
// a real 2-column split at Tablet widths — a deliberate exception to the
// site-wide sm: consolidation, not a leftover of it.
//
// Generic over `sections` — originally written just for /versand
// (VersandTOC), generalized once /datenschutz needed the identical
+3 -3
View File
@@ -15,7 +15,7 @@ export function TestimonialsGrid({ testimonials }: { testimonials: Testimonial[]
if (testimonials.length === 0) return null;
return (
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 sm:py-16 px-[var(--layout-padding-x)]">
<div className="max-w-[1600px] mx-auto w-full flex flex-col gap-8 items-center">
<Reveal
className="font-semibold text-h-emphasis text-text-primary text-center"
@@ -24,11 +24,11 @@ export function TestimonialsGrid({ testimonials }: { testimonials: Testimonial[]
Was andere sagen
</Reveal>
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
<RevealGroup className="grid grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full">
{testimonials.map((t) => (
<RevealItem
key={t.id}
className="group relative md:col-span-4 bg-bg-muted rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
className="group relative sm:col-span-4 bg-bg-muted rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
>
<span
aria-hidden
+25 -13
View File
@@ -20,33 +20,45 @@ 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.
Für mehr Orientierung im Alltag
</p>
</Reveal>
{/* Tools grid — 3 cols md+, stacked below md; internal icon+text layout
stays horizontal at every size, only the outer span changes */}
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-10 md:gap-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
{/* Tools grid — 3 cols sm+ (moved down from the old md: so the grid
arrives where the fluid floor also sits), stacked below sm;
internal icon+text layout stays horizontal at every size, only
the outer span changes */}
<RevealGroup className="grid grid-cols-1 sm:grid-cols-12 gap-10 sm:gap-[var(--layout-grid-gap)] px-[var(--layout-padding-x)] w-full">
{tools.map((tool) => (
<RevealItem
key={tool.id}
className="md:col-span-4 flex gap-8 items-start rounded-md transition-transform duration-300 hover:-translate-y-1"
// Icon above text below lg (1024px) — a 3-up grid from sm
// (640px) leaves each card too narrow for icon+text side by
// side through the whole 640-1023px range. Centered on true
// mobile (matches the rest of this stacked-card pattern
// site-wide), left-aligned from sm up once there's a real
// single-column card width to left-align within.
className="sm:col-span-4 flex flex-col items-center text-center gap-4 sm:items-start sm:text-left lg:flex-row lg:gap-8 rounded-md transition-transform duration-300 hover:-translate-y-1"
>
{/* Icon — uniform box, pre-flipped/rotated source asset.
size-14 (56px) is a fixed value at every width (14 isn't
one of this project's fluid spacing-scale steps) — smaller
below md: so it doesn't dwarf the title/description text,
below lg: so it doesn't dwarf the title/description text,
which does shrink toward its own fluid floor there. Full
56px only from lg: up — at md: (Tablet, where this grid
already switches to 3-up) the title/description are still
fairly close to their own fluid floor, so the full-size
icon read as too big next to them too. */}
56px only from lg: up — a deliberate exception to the
site's sm: (640px) structural consolidation: the grid now
switches to 3-up at sm:, but title/description are still
fairly close to their own fluid floor through the whole
640-1023px range, so the full-size icon still reads too big
next to them there. Not re-verified visually — narrower
exception kept as-is, same reasoning as Hero's content
sizing. */}
<div className="relative flex items-center justify-center shrink-0 size-11 lg:size-14">
<Image alt="" src={tool.icon} fill sizes="(min-width: 1024px) 56px, 44px" className="object-contain" />
</div>
@@ -63,8 +75,8 @@ export async function Tools() {
minimum text→CTA gap even for the tallest card (the one
that defines the row height, and so has ~zero leftover
space for justify-between to distribute on its own). */}
<div className="flex flex-1 flex-col self-stretch h-full justify-between items-start gap-4 min-w-0 text-text-primary [word-break:break-word]">
<div className="flex flex-col gap-4 items-start w-full">
<div className="flex flex-1 flex-col self-stretch h-full justify-between items-center text-center sm:items-start sm:text-left gap-4 min-w-0 text-text-primary [word-break:break-word]">
<div className="flex flex-col gap-4 items-center sm:items-start w-full">
<p
className="font-semibold leading-normal text-h-section w-full"
style={{ fontFamily: "var(--font-lora)" }}
+49 -12
View File
@@ -11,21 +11,58 @@ export async function TrustRow() {
if (items.length === 0) return null;
return (
<div className="w-full bg-bg-base flex flex-col md:flex-row gap-6 md:gap-12 items-start md:items-center justify-center py-8 px-[var(--layout-padding-x)]">
{items.map((item, i) => (
<div key={item.id} className="flex items-center gap-6 md:gap-12">
{i > 0 && <div className="hidden md:block h-10 w-px bg-border" />}
<div className="flex gap-4 items-center">
<div className="relative size-8 shrink-0">
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
<div className="w-full bg-bg-base py-8 px-[var(--layout-padding-x)]">
{/* Below lg (1024px): a CSS Grid with `grid-template-rows: subgrid` —
not a fixed min-height guess — so every badge's "icon+title" box
shares the SAME row track height (auto-sized to whichever
badge's title actually needs 2 lines), and every description
starts exactly at that row's bottom edge regardless of how many
lines its own title happens to wrap to. Icon beside text (the
lg: layout below) is what made a full row of 3 badges too wide
below ~1024px in the first place (see git history: originally
lg:-gated for exactly this, then briefly tried flex-wrap, which
put an odd 3rd item alone on its own wrapped line and read as
disorganized) — icon above text instead shrinks each badge down
to just its text column's width, letting a real row of 3 fit
without wrapping at all. Two structurally different layouts
(icon-above-title here vs. icon-beside-a-title/description-
stack at lg:) don't share one flexible markup shape cleanly, so
this renders as two separate blocks (lg:hidden / hidden lg:flex)
rather than fighting one shape across both breakpoints. */}
<div
className="lg:hidden grid justify-center gap-x-6 gap-y-1"
style={{ gridTemplateColumns: `repeat(${items.length}, auto)`, gridTemplateRows: "repeat(2, auto)" }}
>
{items.map((item) => (
<div key={item.id} className="grid row-span-2 justify-items-center" style={{ gridTemplateRows: "subgrid" }}>
<div className="flex flex-col items-center gap-2">
<div className="relative size-8 shrink-0">
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
</div>
<p className="font-semibold text-body text-text-primary text-center">{item.title}</p>
</div>
<div className="flex flex-col gap-0.5 items-start">
<p className="font-semibold text-body text-text-primary whitespace-nowrap">{item.title}</p>
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.description}</p>
<p className="text-body-sm text-text-muted text-center">{item.description}</p>
</div>
))}
</div>
{/* lg+: original icon-beside-text row with dividers, unchanged. */}
<div className="hidden lg:flex justify-center gap-12 items-center">
{items.map((item, i) => (
<div key={item.id} className="flex items-center gap-12">
{i > 0 && <div className="h-10 w-px bg-border" />}
<div className="flex items-center gap-4">
<div className="relative size-8 shrink-0">
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
</div>
<div className="flex flex-col gap-0.5 items-start">
<p className="font-semibold text-body text-text-primary whitespace-nowrap">{item.title}</p>
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.description}</p>
</div>
</div>
</div>
</div>
))}
))}
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useWishlist } from "../lib/useWishlist";
// Heart-toggle for a product card/detail page. Login-gated (unlike the
// cart, which works for guests) — a logged-out click redirects to
// /konto/login?redirect=<back-here> instead of silently failing, since
// there's no local-storage fallback that would make sense for a wishlist
// (see useWishlist.ts's own comment on why this can't reuse cart.ts's
// guest-friendly pattern).
export function WishlistButton({
productId,
variant = "",
className = "",
revealOnHover = false,
}: {
productId: number;
variant?: string;
className?: string;
/** false (default): always visible — right for single-product contexts
* (ProductSpotlight, /konto/merkliste, the product detail page) where
* there's no "wall of hearts" to thin out. true: invisible until the
* card is hovered/focused, unless the product is already wishlisted (a
* filled heart stays as a permanent status indicator) — right for a
* multi-card grid (ProductGrid.tsx), where a heart on every single card
* reads as visual noise (Marco: "sieht man überall Herzen", 2026-07-31).
* Relies on the parent card already carrying `group`/`focus-within`
* (see ProductGrid.tsx) — Tailwind's plain `:hover`, so tapping a card
* on touch devices reveals it the same way the existing
* `group-hover:-translate-y-1` card-lift already does, no separate
* touch handling needed. */
revealOnHover?: boolean;
}) {
const { isWishlisted, toggle } = useWishlist();
const [pending, setPending] = useState(false);
const router = useRouter();
const wishlisted = isWishlisted(productId, variant);
async function handleClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (pending) return;
const res = await fetch("/api/account/wishlist", { method: "GET", cache: "no-store" });
if (res.status === 401) {
router.push(`/konto/login?redirect=${encodeURIComponent(window.location.pathname)}`);
return;
}
setPending(true);
await toggle(productId, variant);
setPending(false);
}
return (
<button
type="button"
onClick={handleClick}
aria-label={wishlisted ? "Von der Merkliste entfernen" : "Zur Merkliste hinzufügen"}
aria-pressed={wishlisted}
disabled={pending}
className={`flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 backdrop-blur-sm transition-all active:scale-90 disabled:opacity-60 ${
revealOnHover && !wishlisted
? "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100"
: ""
} ${className}`}
>
<svg width="20" height="18" viewBox="0 0 20 18" fill={wishlisted ? "currentColor" : "none"} className={wishlisted ? "text-brand" : "text-text-primary"}>
<path
d="M10 17S1 11.5 1 5.8C1 2.6 3.4 1 5.8 1c1.6 0 3.2.9 4.2 2.4C11 1.9 12.6 1 14.2 1 16.6 1 19 2.6 19 5.8 19 11.5 10 17 10 17Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
</button>
);
}
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { useLivePreview } from "@payloadcms/live-preview-react";
import {
renderOrderConfirmationHtml,
@@ -7,6 +8,7 @@ import {
renderOrderStatusHtml,
ORDER_STATUS_EMAIL_ICON,
SAMPLE_ORDER,
SAMPLE_ORDER_MANUAL,
type EmailTemplateContent,
} from "../../../lib/emailTemplates";
import type { EmailTemplateType } from "../../../lib/payload";
@@ -34,13 +36,19 @@ export function LiveEmailPreviewClient({
depth: 0,
});
// Only order-confirmation has two meaningfully different rendered
// states (isManualPayment true/false change which blocks show at all,
// not just text) — every other type has one sample and no toggle.
const [sampleVariant, setSampleVariant] = useState<"paid" | "manual">("paid");
const orderSample = sampleVariant === "paid" ? SAMPLE_ORDER : SAMPLE_ORDER_MANUAL;
// No real company-settings fetch in this preview context — passing null
// falls back to DEFAULT_LEGAL_FOOTER_LINES (placeholder Anbieterkennzeichnung)
// inside buildLegalFooterLines(), same shape as the real send just with
// placeholder business data.
const html =
type === "order-confirmation"
? renderOrderConfirmationHtml(data, SAMPLE_ORDER, null)
? renderOrderConfirmationHtml(data, orderSample, null)
: type === "password-reset"
? renderPasswordResetHtml(data, "https://einfach-produktiv.mk360.de/konto/passwort-zuruecksetzen?token=beispiel-token", null)
: renderOrderStatusHtml(
@@ -53,7 +61,54 @@ export function LiveEmailPreviewClient({
return (
<div style={{ background: "#f4f2ee", minHeight: "100vh", padding: "32px 0" }}>
<div dangerouslySetInnerHTML={{ __html: html }} />
{type === "order-confirmation" && (
<div style={{ display: "flex", justifyContent: "center", gap: 8, marginBottom: 16 }}>
<button
type="button"
onClick={() => setSampleVariant("paid")}
style={{
padding: "8px 16px",
borderRadius: 999,
border: "1px solid #d1cec4",
background: sampleVariant === "paid" ? "#f6a701" : "#fff",
fontWeight: 700,
fontSize: 13,
cursor: "pointer",
}}
>
Online bezahlt
</button>
<button
type="button"
onClick={() => setSampleVariant("manual")}
style={{
padding: "8px 16px",
borderRadius: 999,
border: "1px solid #d1cec4",
background: sampleVariant === "manual" ? "#f6a701" : "#fff",
fontWeight: 700,
fontSize: 13,
cursor: "pointer",
}}
>
Vorkasse (Überweisung)
</button>
</div>
)}
{/* An iframe, not dangerouslySetInnerHTML into a plain div — `html`
here is a full `<body>...</body>` fragment (see emailTemplates.ts's
emailShell()), meant to become an actual email document. Dropped
directly into this page's own already-existing <body> via
dangerouslySetInnerHTML, that's a nested <body> tag — invalid
HTML the browser "fixes" unpredictably, which is why this preview
used to render broken (wrong background/padding/font, inline
styles not applying). An iframe gives the email HTML its own
real document, exactly like an actual email client would. */}
<iframe
srcDoc={`<!DOCTYPE html><html>${html}</html>`}
title="E-Mail-Vorschau"
style={{ width: "100%", height: "100vh", border: "none", display: "block" }}
/>
</div>
);
}
+8
View File
@@ -16,6 +16,10 @@ const VALID_TYPES: EmailTemplateType[] = [
"order-cancelled",
"order-return-requested",
"order-returned",
"order-tracking-added",
"order-tracking-corrected",
"order-delivered",
"payment-method-switched",
];
const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
@@ -23,6 +27,10 @@ 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",
"payment-method-switched": "Erledigt!",
};
// Entered exclusively via EmailTemplates.ts's admin.livePreview.url (a
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

+57 -53
View File
@@ -2,10 +2,10 @@
/*
* Fluid design tokens — every value below scales continuously via clamp()
* between the Tablet breakpoint (768px) and Desktop (1440px). Below 768px
* the clamp() floor freezes the value automatically (no separate mobile
* media query needed for sizing — only for structural layout changes,
* see the `md:` breakpoint usage in components).
* between the structural breakpoint (640px) and Desktop (1440px). Below
* 640px the clamp() floor freezes the value automatically (no separate
* mobile media query needed for sizing — only for structural layout
* changes, see the `sm:` breakpoint usage in components).
*
* Spacing overrides Tailwind's own numeric scale (--spacing-4 etc.) at the
* exact numbers the styleguide already used (n times 0.25rem equals the
@@ -53,10 +53,10 @@
--shadow-modal: 0 8px 40px rgba(26, 26, 24, 0.16);
--shadow-sidebar: 0 2px 8px rgba(26, 26, 24, 0.08);
/* Type scale (fluid 768->1440) */
--text-display: clamp(2.75rem, 1.3214rem + 2.9762vw, 4rem);
/* Type scale (fluid 640->1440) */
--text-display: clamp(2.75rem, 1.75rem + 2.5vw, 4rem);
--text-display--line-height: 1.1;
--text-h-feature: clamp(1.875rem, 1.1607rem + 1.4881vw, 2.5rem);
--text-h-feature: clamp(1.875rem, 1.375rem + 1.25vw, 2.5rem);
--text-h-feature--line-height: 1.2;
/* New role, added for page-todo-karten's hero heading (48px Desktop) —
doesn't match any existing scale step (h-feature=40, h1=36), Figma's
@@ -67,21 +67,21 @@
etc.)" per styleguide.md §2.2 — a different, smaller role). Also used
by page-weekly-impulses's hero heading — same 48px role, reused
rather than minting a near-duplicate token. */
--text-h-page: clamp(2.5rem, 1.9286rem + 1.1905vw, 3rem);
--text-h-page: clamp(2.5rem, 2.1rem + 1vw, 3rem);
--text-h-page--line-height: 1.15;
--text-h1: clamp(1.75rem, 1.1786rem + 1.1905vw, 2.25rem);
--text-h1: clamp(1.75rem, 1.35rem + 1vw, 2.25rem);
--text-h1--line-height: 1.15;
--text-h2: clamp(1.625rem, 1.1964rem + 0.8929vw, 2rem);
--text-h2: clamp(1.625rem, 1.325rem + 0.75vw, 2rem);
--text-h2--line-height: 1.25;
--text-h-section: clamp(1.4375rem, 1.0804rem + 0.744vw, 1.75rem);
--text-h-section: clamp(1.4375rem, 1.1875rem + 0.625vw, 1.75rem);
--text-h-section--line-height: 1.3;
--text-h-emphasis: clamp(1.25rem, 0.9643rem + 0.5952vw, 1.5rem);
--text-h-emphasis: clamp(1.25rem, 1.05rem + 0.5vw, 1.5rem);
--text-h-emphasis--line-height: 1.4;
--text-h3: clamp(1.1875rem, 0.9732rem + 0.4464vw, 1.375rem);
--text-h3: clamp(1.1875rem, 1.0375rem + 0.375vw, 1.375rem);
--text-h3--line-height: 1.35;
--text-h-small: clamp(1.125rem, 0.9821rem + 0.2976vw, 1.25rem);
--text-h-small: clamp(1.125rem, 1.025rem + 0.25vw, 1.25rem);
--text-h-small--line-height: 1.4;
--text-h4: clamp(1rem, 0.8571rem + 0.2976vw, 1.125rem);
--text-h4: clamp(1rem, 0.9rem + 0.25vw, 1.125rem);
--text-h4--line-height: 1.4;
--text-body: 1rem;
--text-body--line-height: 1.6;
@@ -90,21 +90,21 @@
--text-label: 0.75rem;
--text-label--line-height: 1.4;
/* Spacing scale (fluid 768->1440) — overrides Tailwind's default n*0.25rem
/* Spacing scale (fluid 640->1440) — overrides Tailwind's default n*0.25rem
at these exact steps, so p-4/gap-6/px-20/etc. become fluid automatically */
--spacing-1: clamp(0.25rem, 0.25rem + 0vw, 0.25rem);
--spacing-2: clamp(0.5rem, 0.5rem + 0vw, 0.5rem);
--spacing-3: clamp(0.625rem, 0.4821rem + 0.2976vw, 0.75rem);
--spacing-4: clamp(0.875rem, 0.7321rem + 0.2976vw, 1rem);
--spacing-5: clamp(1rem, 0.7143rem + 0.5952vw, 1.25rem);
--spacing-6: clamp(1.25rem, 0.9643rem + 0.5952vw, 1.5rem);
--spacing-8: clamp(1.5rem, 0.9286rem + 1.1905vw, 2rem);
--spacing-10: clamp(1.75rem, 0.8929rem + 1.7857vw, 2.5rem);
--spacing-12: clamp(2rem, 0.8571rem + 2.381vw, 3rem);
--spacing-16: clamp(2.5rem, 0.7857rem + 3.5714vw, 4rem);
--spacing-20: clamp(3rem, 0.7143rem + 4.7619vw, 5rem);
--spacing-24: clamp(3.5rem, 0.6429rem + 5.9524vw, 6rem);
--spacing-30: clamp(4rem, 0rem + 8.3333vw, 7.5rem);
--spacing-3: clamp(0.625rem, 0.525rem + 0.25vw, 0.75rem);
--spacing-4: clamp(0.875rem, 0.775rem + 0.25vw, 1rem);
--spacing-5: clamp(1rem, 0.8rem + 0.5vw, 1.25rem);
--spacing-6: clamp(1.25rem, 1.05rem + 0.5vw, 1.5rem);
--spacing-8: clamp(1.5rem, 1.1rem + 1vw, 2rem);
--spacing-10: clamp(1.75rem, 1.15rem + 1.5vw, 2.5rem);
--spacing-12: clamp(2rem, 1.2rem + 2vw, 3rem);
--spacing-16: clamp(2.5rem, 1.3rem + 3vw, 4rem);
--spacing-20: clamp(3rem, 1.4rem + 4vw, 5rem);
--spacing-24: clamp(3.5rem, 1.5rem + 5vw, 6rem);
--spacing-30: clamp(4rem, 1.2rem + 7vw, 7.5rem);
/* Transitions */
--transition-fast: 150ms ease;
@@ -116,18 +116,22 @@
/* Layout — not part of Tailwind's numeric spacing scale, referenced via
arbitrary-value syntax (e.g. px-[var(--layout-padding-x)]) */
--layout-max-width: 75rem; /* 1200px */
--layout-padding-x: clamp(2rem, -1.4286rem + 7.1429vw, 5rem);
--layout-grid-gap: clamp(1rem, 0.4286rem + 1.1905vw, 1.5rem);
--layout-grid-gap-lg: clamp(1.25rem, 0.3929rem + 1.7857vw, 2rem);
--layout-padding-x: clamp(2rem, -0.4rem + 6vw, 5rem);
--layout-grid-gap: clamp(1rem, 0.6rem + 1vw, 1.5rem);
--layout-grid-gap-lg: clamp(1.25rem, 0.65rem + 1.5vw, 2rem);
/* One-off fluid values that aren't part of a broader scale */
--hero-avatar-size: clamp(2.25rem, 1.6071rem + 1.3393vw, 2.8125rem);
--hero-avatar-size: clamp(2.25rem, 1.8rem + 1.125vw, 2.8125rem);
/* Newsletter copy column — was a hard 38.25rem, which didn't leave enough
room for the form column at the 768px tablet floor (only 656px of
content width available for 612px column plus 56px gap), crushing the
input/button/privacy text to near-zero width and overflowing the page.
Scales down to 23rem at 768px instead. */
--newsletter-copy-width: clamp(23rem, 5.5714rem + 36.3095vw, 38.25rem);
room for the form column at the 640px structural floor (only ~576px of
content width available for a 612px column plus gap at that width),
crushing the input/button/privacy text to near-zero width and
overflowing the page. Scales down to 23rem at 640px instead. Note: the
inner input+button row still switches to a row layout at `lg:` (1024px),
not at the site's `sm:` (640px) structural line — even at this reduced
floor there isn't enough room for input+button side by side below
~900px, see Newsletter.tsx's own comment. */
--newsletter-copy-width: clamp(23rem, 10.8rem + 30.5vw, 38.25rem);
/* Divider section icons (arrow separators, sparkle) — previously hard
rem values while the "Klarheit/Fokus/Entlastung" text next to them
@@ -135,30 +139,30 @@
while the words scaled. Same fluid(minPx, maxPx) formula as the rest
of this file, floor ~82.5% of the Desktop value per the styleguide's
80-85% Mobile-ratio guidance. */
--divider-arrow-w: clamp(2.125rem, 1.5536rem + 1.1905vw, 2.625rem);
--divider-arrow-h: clamp(0.625rem, 0.4821rem + 0.2976vw, 0.75rem);
--divider-sparkle-w: clamp(1.5625rem, 1.1554rem + 0.8482vw, 1.91875rem);
--divider-sparkle-h: clamp(2.0625rem, 1.5554rem + 1.0565vw, 2.50625rem);
--divider-sparkle-inner-w: clamp(1.4375rem, 1.09875rem + 0.706vw, 1.734rem);
--divider-sparkle-inner-h: clamp(1.9375rem, 1.4375rem + 1.0417vw, 2.375rem);
--divider-arrow-w: clamp(2.125rem, 1.725rem + 1vw, 2.625rem);
--divider-arrow-h: clamp(0.625rem, 0.525rem + 0.25vw, 0.75rem);
--divider-sparkle-w: clamp(1.5625rem, 1.2775rem + 0.7125vw, 1.9188rem);
--divider-sparkle-h: clamp(2.0625rem, 1.7075rem + 0.8875vw, 2.5063rem);
--divider-sparkle-inner-w: clamp(1.4375rem, 1.2003rem + 0.593vw, 1.734rem);
--divider-sparkle-inner-h: clamp(1.9375rem, 1.5875rem + 0.875vw, 2.375rem);
/* Own token, mirrors --text-h2's clamp() exactly rather than the Word
component reading var(--text-h2) directly — that's what lets the
mobile override below shrink just this component's words without
touching every other text-h2 heading site-wide. */
--divider-word-size: clamp(1.625rem, 1.1964rem + 0.8929vw, 2rem);
--divider-word-size: clamp(1.625rem, 1.325rem + 0.75vw, 2rem);
}
/* This project's fluid() scale (see fluid.ts) is calibrated for the
768-1440px Tablet-Desktop range and floors out at the 768px value for
any narrower viewport (clamp()'s MIN bound) — by design, see the other
fluid tokens above. Divider is the one spot that floor doesn't work:
the "Klarheit → Fokus → Entlastung" phrase plus its connector icons
needs ~550px of width to lay out on one row even at the 768px floor
size, far more than a phone's ~310px content width. Below Tailwind's
sm: breakpoint, shrink these tokens further so the phrase gets much
closer to fitting on one row instead of stacking into three separate
centered lines (see Divider.tsx's gap-x-3/gap-3 mobile overrides,
same breakpoint). Scoped to these component-only tokens, not
640-1440px structural-to-Desktop range and floors out at the 640px value
for any narrower viewport (clamp()'s MIN bound) — by design, see the
other fluid tokens above. Divider is the one spot that floor doesn't
work: the "Klarheit → Fokus → Entlastung" phrase plus its connector
icons needs ~550px of width to lay out on one row even at the 640px
floor size, far more than a phone's ~310px content width. Below
Tailwind's sm: breakpoint, shrink these tokens further so the phrase
gets much closer to fitting on one row instead of stacking into three
separate centered lines (see Divider.tsx's gap-x-3/gap-3 mobile
overrides, same breakpoint). Scoped to these component-only tokens, not
--text-h2 itself. */
@media (max-width: 639px) {
:root {
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect x="0" y="0" width="64" height="64" rx="14" fill="#f6a701" />
<text
x="32"
y="46"
text-anchor="middle"
font-family="Georgia, 'Playfair Display', 'Times New Roman', serif"
font-weight="700"
font-style="italic"
font-size="42"
fill="#1a1a1a"
>e</text>
</svg>

After

Width:  |  Height:  |  Size: 383 B

+14 -7
View File
@@ -35,7 +35,8 @@ import type { TOCSection } from "../../components/SectionTOC";
// basis first, and don't assume the old attempt's reasoning was correct.
export function anbieterAngabenHeadings(seller: CompanySettings | null): TOCSection[] {
if (!seller) return [];
const sections = ["Angaben zum Anbieter", "Umsatzsteuer"];
const sections = ["Angaben zum Anbieter"];
if (seller.vatId) sections.push("Umsatzsteuer");
if (seller.registerCourt && seller.registerNumber) sections.push("Handelsregister");
if (seller.managingDirector) sections.push("Geschäftsführung");
sections.push("Verantwortlich für den Inhalt");
@@ -75,14 +76,20 @@ export function AnbieterAngaben({ seller }: { seller: CompanySettings }) {
{seller.sellerZip} {seller.sellerCity}
</P>
<P>{seller.sellerCountry}</P>
<P>E-Mail: {seller.sellerEmail}</P>
<P>
E-Mail: <a href={`mailto:${seller.sellerEmail}`} className="text-brand hover:underline">{seller.sellerEmail}</a>
</P>
</div>
<Heading>Umsatzsteuer</Heading>
<div className="flex flex-col gap-1">
<P>Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:</P>
<P>{seller.vatId}</P>
</div>
{seller.vatId && (
<>
<Heading>Umsatzsteuer</Heading>
<div className="flex flex-col gap-1">
<P>Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:</P>
<P>{seller.vatId}</P>
</div>
</>
)}
{seller.registerCourt && seller.registerNumber && (
<>
@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { PaymentStep } from "../../../../checkout/components/PaymentStep";
// Shown only for a still-unpaid Überweisung order (see page.tsx's own
// eligibility check, mirroring api/account/orders/[orderNumber]/
// switch-to-stripe/route.ts's authoritative one) — lets a customer switch
// to Kreditkarte/PayPal instead of waiting on their own bank transfer.
// Reuses PaymentStep (the exact same Stripe collection UI checkout uses)
// once this endpoint hands back a clientSecret — the order already
// exists, this only changes how it gets paid.
export function SwitchPaymentButton({ orderNumber }: { orderNumber: string }) {
const [state, setState] = useState<
| { step: "idle" }
| { step: "loading" }
| { step: "error"; reason: string }
| { step: "paying"; clientSecret: string; orderId: number; testMode: boolean; providerReference?: string }
>({ step: "idle" });
async function start() {
setState({ step: "loading" });
try {
const res = await fetch(`/api/account/orders/${encodeURIComponent(orderNumber)}/switch-to-stripe`, {
method: "POST",
});
const data = await res.json();
if (!data.ok) {
setState({ step: "error", reason: data.reason || "Umstellung fehlgeschlagen." });
return;
}
setState({
step: "paying",
clientSecret: data.clientSecret,
orderId: data.orderId,
testMode: Boolean(data.testMode),
providerReference: data.providerReference,
});
} catch {
setState({ step: "error", reason: "Umstellung gerade nicht möglich." });
}
}
if (state.step === "paying") {
return (
<div className="flex flex-col gap-4 w-full border border-border rounded-md p-5">
<p className="font-semibold text-body-sm text-text-primary">Mit Kreditkarte/PayPal bezahlen</p>
<PaymentStep
clientSecret={state.clientSecret}
orderNumber={orderNumber}
orderId={state.orderId}
testMode={state.testMode}
providerReference={state.providerReference}
returnContext="account"
/>
</div>
);
}
return (
<div className="flex flex-col gap-2 items-start">
<button
type="button"
onClick={start}
disabled={state.step === "loading"}
className={`px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors ${state.step === "loading" ? "opacity-70 pointer-events-none" : ""}`}
>
{state.step === "loading" ? "…" : "Zahlungsart ändern"}
</button>
{state.step === "error" && <p className="text-label text-red-600">{state.reason}</p>}
</div>
);
}
+44 -8
View File
@@ -7,11 +7,13 @@ import { Footer } from "../../../components/Footer";
import { VatBreakdown } from "../../../components/VatBreakdown";
import { formatPrice, formatDate } from "../../../lib/format";
import { getSessionCustomer, getCustomerOrderDetail, customerOrderAction } from "../../../lib/customerAuth";
import { getProductImagesByIds } from "../../../lib/payload";
import { getProductImagesByIds, getPaymentMethods, groupPaymentMethodsForCheckout, getMediaUrlById } from "../../../lib/payload";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import { buildTrackingUrl, CARRIER_LABELS } from "../../../lib/tracking";
import { OrderActionButton } from "./components/OrderActionButton";
import { SwitchPaymentButton } from "./components/SwitchPaymentButton";
import { OrderStatusBadge } from "../../components/OrderStatusBadge";
import { PaymentStatusBadge } from "../../components/PaymentStatusBadge";
// Dynamic (was a static "Bestelldetails" title despite this being a
// per-order route) — just formats the already-known order number into
@@ -33,7 +35,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 =
@@ -46,7 +48,16 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
: order.shippingStreet;
const action = customerOrderAction(order.status);
const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
const dhlReturnLabel = order.dhlReturnLabelMedia ? await getMediaUrlById(order.dhlReturnLabelMedia) : null;
const taxBreakdown = computeTaxBreakdown(order.items, order.subtotal, order.discountAmount, order.shippingCost);
// Same "Online-Zahlung" grouping/eligibility the switch-to-stripe route
// itself re-checks authoritatively — only offer the button when it
// would actually succeed.
const canSwitchPayment =
order.paymentProvider === "manual" &&
order.status === "received" &&
(order.paymentStatus === "pending" || order.paymentStatus === "not_applicable") &&
groupPaymentMethodsForCheckout(await getPaymentMethods()).some((m) => m.provider === "stripe");
return (
<>
@@ -73,6 +84,10 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
<p className="text-label text-text-muted">Zahlungsart</p>
<p className="text-body-sm text-text-primary">{order.paymentMethodTitle}</p>
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Zahlungsstatus</p>
<PaymentStatusBadge paymentStatus={order.paymentStatus} />
</div>
</div>
{order.trackingNumber && (
@@ -91,6 +106,15 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
</div>
)}
{dhlReturnLabel && (
<div className="flex flex-col gap-1 w-full">
<p className="text-label text-text-muted">DHL-Retourenschein{order.dhlReturnTrackingNumber ? ` (${order.dhlReturnTrackingNumber})` : ""}</p>
<a href={dhlReturnLabel.url} target="_blank" rel="noopener noreferrer" className="text-body-sm text-brand hover:underline">
Retourenschein herunterladen
</a>
</div>
)}
<div className="flex flex-col gap-1 w-full">
{/* Labeled "Rechnungsadresse" only once there's an actual
second (shipping) address to distinguish it from — the
@@ -120,6 +144,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
{order.hasDifferentShippingAddress && (
<div className="flex flex-col gap-1 w-full">
<p className="text-label text-text-muted">Lieferadresse</p>
{order.shippingCompanyName && <p className="text-body-sm text-text-primary">{order.shippingCompanyName}</p>}
<p className="text-body-sm text-text-primary">
{order.shippingFirstName} {order.shippingLastName}
</p>
@@ -127,6 +152,11 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
<p className="text-body-sm text-text-primary">
{order.shippingZip} {order.shippingCity}, {order.shippingCountry}
</p>
{(order.shippingContactEmail || order.shippingContactPhone) && (
<p className="text-body-sm text-text-muted">
{[order.shippingContactEmail, order.shippingContactPhone].filter(Boolean).join(" · ")}
</p>
)}
</div>
)}
@@ -145,6 +175,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
</p>
{!order.kleinunternehmer && <p className="text-label text-text-muted">inkl. {item.taxRatePercent}% MwSt.</p>}
{item.bundleContents && <p className="text-label text-text-muted">{item.bundleContents}</p>}
{item.sku && <p className="text-label text-text-muted">Art.-Nr. {item.sku}</p>}
{item.returnQuantity > 0 && (
<p className="text-label text-text-muted">davon {item.returnQuantity} zurückgesendet</p>
)}
@@ -220,12 +251,17 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
)}
</div>
{action && (
<OrderActionButton
orderNumber={order.orderNumber}
action={action}
items={order.items.map((item) => ({ product: item.product, productName: item.productName, quantity: item.quantity }))}
/>
{(action || canSwitchPayment) && (
<div className="flex flex-wrap gap-3 items-start w-full">
{action && (
<OrderActionButton
orderNumber={order.orderNumber}
action={action}
items={order.items.map((item) => ({ product: item.product, productName: item.productName, quantity: item.quantity }))}
/>
)}
{canSwitchPayment && <SwitchPaymentButton orderNumber={order.orderNumber} />}
</div>
)}
</Reveal>
</main>
@@ -0,0 +1,61 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { CustomSelect } from "../../../components/CustomSelect";
// Three custom-styled dropdowns in a row instead of a wall of filter
// chips (tried first, reverted 2026-07-30 — with 7 status options + 5
// payment-status options + N years, a chip per option read as cluttered
// and ate a lot of vertical space) or plain native <select>s (tried next,
// also reverted the same day — a native <select>'s open options popup is
// rendered by the browser/OS and can't be styled at all, so it looked
// completely off-brand next to everything else on the page; see
// CustomSelect.tsx for the fully custom-styled replacement). Client
// Component only for the onChange→navigate wiring; the actual filtering
// still happens server-side in page.tsx via the same URL search params,
// so this stays a plain GET-style filter (shareable/bookmarkable/
// back-button-safe), not client-side state.
export function OrderFilters({
statusOptions,
paymentStatusOptions,
years,
}: {
statusOptions: { value: string; label: string }[];
paymentStatusOptions: { value: string; label: string }[];
years: string[];
}) {
const router = useRouter();
const searchParams = useSearchParams();
const status = searchParams.get("status") ?? "";
const paymentStatus = searchParams.get("paymentStatus") ?? "";
const year = searchParams.get("year") ?? "";
const hasAnyFilter = Boolean(status || paymentStatus || year);
function setParam(key: string, value: string) {
const params = new URLSearchParams(searchParams.toString());
if (value) params.set(key, value);
else params.delete(key);
const qs = params.toString();
router.push(qs ? `/konto/bestellungen?${qs}` : "/konto/bestellungen");
}
const yearOptions = years.map((y) => ({ value: y, label: y }));
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3 w-full">
<CustomSelect label="Alle Status" options={statusOptions} value={status} onChange={(v) => setParam("status", v)} />
<CustomSelect label="Alle Zahlungsstatus" options={paymentStatusOptions} value={paymentStatus} onChange={(v) => setParam("paymentStatus", v)} />
<CustomSelect label="Alle Jahre" options={yearOptions} value={year} onChange={(v) => setParam("year", v)} />
{hasAnyFilter && (
<button
type="button"
onClick={() => router.push("/konto/bestellungen")}
className="text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors self-start sm:self-auto"
>
Zurücksetzen
</button>
)}
</div>
);
}
+86 -14
View File
@@ -1,12 +1,21 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { redirect } from "next/navigation";
import Link from "next/link";
import { Reveal } from "../../components/Reveal";
import { Footer } from "../../components/Footer";
import { formatPrice, formatDate } from "../../lib/format";
import { getSessionCustomer, getCustomerOrders } from "../../lib/customerAuth";
import {
getSessionCustomer,
getCustomerOrders,
getCustomerOrderYears,
ORDER_STATUS_LABEL,
} from "../../lib/customerAuth";
import { getOrderFilterEnabled } from "../../lib/payload";
import { OrderStatusBadge } from "../components/OrderStatusBadge";
import { PaymentStatusBadge } from "../components/PaymentStatusBadge";
import { LogoutButton } from "../components/LogoutButton";
import { OrderFilters } from "./components/OrderFilters";
// robots: noindex — account area, same reasoning as /checkout.
export const metadata: Metadata = {
@@ -18,11 +27,37 @@ export const metadata: Metadata = {
},
};
export default async function KontoBestellungenPage() {
// Mirrors PaymentStatusBadge's own grouping ("Offen" covers both
// not_applicable and pending) — see getCustomerOrders's OPEN_PAYMENT_STATUSES.
const PAYMENT_STATUS_FILTER_LABEL: Record<string, string> = {
open: "Offen",
paid: "Bezahlt",
failed: "Fehlgeschlagen",
refunded: "Erstattet",
partially_refunded: "Teilweise erstattet",
};
const STATUS_OPTIONS = Object.entries(ORDER_STATUS_LABEL).map(([value, label]) => ({ value, label }));
const PAYMENT_STATUS_OPTIONS = Object.entries(PAYMENT_STATUS_FILTER_LABEL).map(([value, label]) => ({ value, label }));
export default async function KontoBestellungenPage({
searchParams,
}: {
searchParams: Promise<{ status?: string; paymentStatus?: string; year?: string }>;
}) {
const session = await getSessionCustomer();
if (!session) redirect("/konto/login");
const orders = await getCustomerOrders(session.token, session.customer.id);
const orderFilterEnabled = await getOrderFilterEnabled();
const rawSearchParams = await searchParams;
const status = orderFilterEnabled ? rawSearchParams.status : undefined;
const paymentStatus = orderFilterEnabled ? rawSearchParams.paymentStatus : undefined;
const year = orderFilterEnabled ? rawSearchParams.year : undefined;
const [orders, availableYears] = await Promise.all([
getCustomerOrders(session.token, session.customer.id, true, { status, paymentStatus, year }),
getCustomerOrderYears(session.token, session.customer.id),
]);
return (
<>
@@ -35,33 +70,63 @@ export default async function KontoBestellungenPage() {
Eingeloggt als {session.customer.email} (Kundennummer {session.customer.customerNumber})
</p>
{orders.length === 0 ? (
{orderFilterEnabled && availableYears.length > 0 && (
<Suspense fallback={null}>
<OrderFilters statusOptions={STATUS_OPTIONS} paymentStatusOptions={PAYMENT_STATUS_OPTIONS} years={availableYears} />
</Suspense>
)}
{availableYears.length === 0 ? (
<p className="text-body text-text-muted">Du hast noch keine Bestellung aufgegeben.</p>
) : orders.length === 0 ? (
<p className="text-body text-text-muted">Keine Bestellungen gefunden, die zu den gewählten Filtern passen.</p>
) : (
<div className="flex flex-col gap-4 w-full">
{orders.map((order) => (
<Link
key={order.orderNumber}
href={`/konto/bestellungen/${encodeURIComponent(order.orderNumber)}`}
className="flex flex-wrap items-center gap-4 w-full bg-bg-base border border-border rounded-md p-6 hover:border-brand transition-colors"
className="grid grid-cols-2 sm:grid-cols-4 gap-x-3 sm:gap-x-6 gap-y-4 w-full bg-bg-base border border-border rounded-md p-6 hover:border-brand transition-colors"
>
<div className="flex flex-col gap-1">
{/* Below sm (640px): 2 columns, plain DOM order —
Bestellnummer/Datum, Status/Zahlungsstatus,
Artikel/Gesamtbetrag tile into exactly those 3 pairs
on their own, no explicit placement needed. From sm
up: 4 columns, every cell explicitly pinned to a
row+column (sm:row-start-N sm:col-start-N) rather
than relying on `order` + Grid's auto-placement —
mixing `order` with only SOME items explicitly
positioned is a real footgun: the auto-placement
cursor for the un-pinned items starts scanning from
column 1 again regardless of what's already
explicitly placed elsewhere. Pinning every cell's row
AND column explicitly removes that ambiguity
entirely. Row 1: Bestellnummer/Datum in columns 1-2,
left-aligned (centering them in columns 2-3 was
tried and reverted — not wanted). Row 2: Artikel/
Status/Zahlungsstatus/Gesamtbetrag fill all 4
columns. */}
<div className="flex flex-col gap-1 sm:row-start-1 sm:col-start-1">
<p className="text-label text-text-muted">Bestellnummer</p>
<p className="font-bold text-body-sm text-text-primary">{order.orderNumber}</p>
</div>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1 sm:row-start-1 sm:col-start-2">
<p className="text-label text-text-muted">Datum</p>
<p className="text-body-sm text-text-primary">{formatDate(order.createdAt)}</p>
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Artikel</p>
<p className="text-body-sm text-text-primary">{order.itemCount}</p>
</div>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1 sm:row-start-2 sm:col-start-2">
<p className="text-label text-text-muted">Status</p>
<OrderStatusBadge status={order.status} />
</div>
<div className="flex flex-col gap-1 ml-auto">
<div className="flex flex-col gap-1 sm:row-start-2 sm:col-start-3">
<p className="text-label text-text-muted">Zahlungsstatus</p>
<PaymentStatusBadge paymentStatus={order.paymentStatus} />
</div>
<div className="flex flex-col gap-1 sm:row-start-2 sm:col-start-1">
<p className="text-label text-text-muted">Artikel</p>
<p className="text-body-sm text-text-primary">{order.itemCount}</p>
</div>
<div className="flex flex-col gap-1 sm:row-start-2 sm:col-start-4">
<p className="text-label text-text-muted">Gesamtbetrag</p>
<p className="font-bold text-body-sm text-text-primary">{formatPrice(order.total)}</p>
</div>
@@ -70,7 +135,14 @@ export default async function KontoBestellungenPage() {
</div>
)}
<div className="flex gap-6">
{/* self-center below sm — this row otherwise inherits the parent
Reveal's items-start (left-aligned); centered here per its
own request, but only the row itself (self-center keeps its
shrink-to-fit content width, doesn't stretch it to the full
parent width the way w-full/justify-center on the parent
would). Back to left-aligned (matching the rest of the page)
from sm up. */}
<div className="flex gap-6 self-center sm:self-start">
<Link href="/konto/profil" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Profil &amp; Adresse
</Link>
+8
View File
@@ -2,12 +2,20 @@
import { useRouter } from "next/navigation";
import { dispatchAuthChanged } from "../../lib/auth";
import { clearCart } from "../../lib/cart";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
await fetch("/api/account/logout", { method: "POST" });
// The local cart is already mirrored server-side by CartSync, so it's
// safe to clear it here — the next login's mergeServerCartIntoLocal()
// restores it from the server. Without this, the local cart survived
// logout untouched, and mergeServerCartIntoLocal()'s additive merge
// (existing.qty += qty) would add the already-synced server quantities
// on top of it on every login, doubling every logout/login cycle.
clearCart();
dispatchAuthChanged();
router.push("/");
router.refresh();
+1 -1
View File
@@ -20,7 +20,7 @@ const STYLES: Record<string, string> = {
export function OrderStatusBadge({ status }: { status: string }) {
return (
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-label font-bold whitespace-nowrap ${STYLES[status] ?? "bg-bg-muted text-text-muted"}`}
className={`self-start inline-flex items-center px-2.5 py-1 rounded-full text-label font-bold whitespace-nowrap ${STYLES[status] ?? "bg-bg-muted text-text-muted"}`}
>
{ORDER_STATUS_LABEL[status] ?? status}
</span>
@@ -0,0 +1,33 @@
// Same visual pattern as OrderStatusBadge — but answers a different
// question ("hat Stripe/die Buchhaltung eine Zahlung bestätigt?", not
// "wo im Fulfillment steht die Bestellung"). 'not_applicable' (an
// Überweisung order before payment is manually reconciled) reads as
// "offen", same as a still-'pending' Stripe order — the customer doesn't
// need to know the internal distinction between the two.
const LABEL: Record<string, string> = {
not_applicable: "Offen",
pending: "Offen",
paid: "Bezahlt",
failed: "Fehlgeschlagen",
refunded: "Erstattet",
partially_refunded: "Teilweise erstattet",
};
const STYLES: Record<string, string> = {
not_applicable: "bg-red-50 text-red-600",
pending: "bg-red-50 text-red-600",
paid: "bg-success-subtle text-success",
failed: "bg-red-50 text-red-600",
refunded: "bg-bg-muted text-text-light",
partially_refunded: "bg-orange-50 text-orange-600",
};
export function PaymentStatusBadge({ paymentStatus }: { paymentStatus: string }) {
return (
<span
className={`self-start inline-flex items-center px-2.5 py-1 rounded-full text-label font-bold whitespace-nowrap ${STYLES[paymentStatus] ?? "bg-bg-muted text-text-muted"}`}
>
{LABEL[paymentStatus] ?? paymentStatus}
</span>
);
}
+16 -7
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Reveal } from "../../../components/Reveal";
import { mergeServerCartIntoLocal } from "../../../lib/cart";
@@ -9,6 +9,12 @@ import { dispatchAuthChanged } from "../../../lib/auth";
export function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
// WishlistButton (and anything else login-gated) sends the shopper back
// here with ?redirect=<where they were>, e.g. a product page they
// wanted to heart — previously ignored, always landing on
// /konto/bestellungen regardless of where the login was triggered from.
const redirectTo = searchParams.get("redirect") || "/konto/bestellungen";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -32,7 +38,7 @@ export function LoginForm() {
}
await mergeServerCartIntoLocal();
dispatchAuthChanged();
router.push("/konto/bestellungen");
router.push(redirectTo);
router.refresh();
} catch {
setError("Login ist gerade nicht möglich.");
@@ -81,11 +87,14 @@ export function LoginForm() {
Passwort vergessen?
</Link>
<p className="text-body-sm text-text-muted">
Noch kein Konto? Einfach beim{" "}
<Link href="/checkout" className="underline hover:text-brand transition-colors">
nächsten Einkauf
</Link>{" "}
anlegen.
Noch kein Konto?{" "}
<Link
href={`/konto/registrieren${redirectTo !== "/konto/bestellungen" ? `?redirect=${encodeURIComponent(redirectTo)}` : ""}`}
className="underline hover:text-brand transition-colors"
>
Jetzt anlegen
</Link>
.
</p>
</Reveal>
);
+7 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { LoginForm } from "./components/LoginForm";
import { Footer } from "../../components/Footer";
@@ -16,7 +17,12 @@ export default function KontoLoginPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<LoginForm />
{/* Suspense required — LoginForm uses useSearchParams() (?redirect=,
e.g. from WishlistButton's login-gate) which opts any consumer
into client-side rendering unless wrapped. */}
<Suspense fallback={null}>
<LoginForm />
</Suspense>
</main>
<Footer />
</>
@@ -0,0 +1,88 @@
"use client";
import Image from "next/image";
import { RevealGroup, RevealItem } from "../../../components/Reveal";
import { formatPrice, discountPercent } from "../../../lib/format";
import { AddToCartInlineButton } from "../../../components/AddToCartInlineButton";
import { WishlistButton } from "../../../components/WishlistButton";
import { useWishlist } from "../../../lib/useWishlist";
import { effectiveTaxRate } from "../../../lib/cartTotals";
import type { Product } from "../../../lib/payload";
// Client Component so removing an item (WishlistButton toggling it off)
// disappears from this grid immediately — the server-rendered initial
// list alone doesn't react to that client-side toggle at all (the parent
// page.tsx is a Server Component, its render is fixed at request time).
// useWishlist()'s live `items` is the actual source of truth for which
// products are still wishlisted; `initialProducts` only supplies the
// display data (name/image/price) for whatever numericIds are currently
// wishlisted, since the wishlist itself only stores product ids.
export function MerklisteGrid({
initialProducts,
defaultTaxRate,
kleinunternehmer,
}: {
initialProducts: Product[];
defaultTaxRate: number;
kleinunternehmer: boolean;
}) {
const { items } = useWishlist();
const wishlistedIds = new Set(items.map((i) => i.productId));
// Preserve the wishlist's own order (most-recently-added-first, via
// `items`) rather than initialProducts' own order.
const productsByNumericId = new Map(initialProducts.map((p) => [p.numericId, p]));
const visibleProducts = items.map((i) => productsByNumericId.get(i.productId)).filter((p): p is Product => Boolean(p));
if (visibleProducts.length === 0) {
return <p className="text-body text-text-muted">Du hast noch keine Produkte gemerkt.</p>;
}
return (
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
{visibleProducts.map((product) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
return (
<RevealItem key={product.id} className="bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full">
<div className="relative w-full aspect-[276/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className={`object-cover ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
{fullyOutOfStock && (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
)}
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
{product.name}
</p>
<div className="flex flex-col gap-1 items-start">
<p className="flex items-baseline gap-1.5">
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
</div>
{/* No className override — AddToCartInlineButton's `className`
prop REPLACES its whole default styling (`?? defaultClass`,
not a merge), so passing just "w-full" here previously threw
away all the button's actual styling. Its default is
already `w-full`. */}
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
</div>
</RevealItem>
);
})}
</RevealGroup>
);
}
+61
View File
@@ -0,0 +1,61 @@
import type { Metadata } from "next";
import { redirect, notFound } from "next/navigation";
import Link from "next/link";
import { Reveal } from "../../components/Reveal";
import { Footer } from "../../components/Footer";
import { getSessionCustomer, getWishlist } from "../../lib/customerAuth";
import { getProductsByIds, getWishlistEnabled, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { MerklisteGrid } from "./components/MerklisteGrid";
// robots: noindex — account area, same reasoning as /konto/bestellungen.
export const metadata: Metadata = {
title: "Meine Merkliste",
description: "Deine gemerkten Produkte bei einfach produktiv.",
robots: {
index: false,
follow: true,
},
};
export default async function KontoMerklistePage() {
const session = await getSessionCustomer();
if (!session) redirect("/konto/login");
// The feature can be switched off after a customer already had rows in
// their wishlist — 404 rather than showing a stale page for a feature
// that's no longer offered, same reasoning as any other feature-flagged
// route in this codebase.
const wishlistEnabled = await getWishlistEnabled();
if (!wishlistEnabled) notFound();
const [wishlistItems, defaultTaxRate, kleinunternehmer] = await Promise.all([
getWishlist(session.token, session.customer.id),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
const initialProducts = await getProductsByIds(wishlistItems.map((i) => i.productId));
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[75rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Meine Merkliste
</p>
<MerklisteGrid initialProducts={initialProducts} defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} />
<div className="flex gap-6 self-center sm:self-start">
<Link href="/konto/bestellungen" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Meine Bestellungen
</Link>
<Link href="/shop" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Weiter einkaufen
</Link>
</div>
</Reveal>
</main>
<Footer />
</>
);
}
+146 -41
View File
@@ -34,7 +34,10 @@ export function ProfileForm({
shippingCountries: ShippingCountry[];
}) {
const router = useRouter();
const [deliveryMethod, setDeliveryMethod] = useState<"address" | "packstation">(profile.deliveryMethod ?? "address");
const [hasDifferentShippingAddress, setHasDifferentShippingAddress] = useState(profile.hasDifferentShippingAddress);
const [shippingDeliveryMethod, setShippingDeliveryMethod] = useState<"address" | "packstation">(
profile.shippingDeliveryMethod ?? "address",
);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [saving, setSaving] = useState(false);
@@ -49,15 +52,32 @@ export function ProfileForm({
const body = {
firstName: String(form.get("firstName") ?? ""),
lastName: String(form.get("lastName") ?? ""),
deliveryMethod,
// Always a plain street address — same "Rechnungsadresse is never a
// Packstation" rule as /checkout's own billing card (an invoice
// needs a real postal address). Packstation is only ever offered
// below, in the optional "Lieferadresse" section.
deliveryMethod: "address" as const,
street: String(form.get("street") ?? "") || undefined,
packstationNumber: String(form.get("packstationNumber") ?? "") || undefined,
postNumber: String(form.get("postNumber") ?? "") || undefined,
zip: String(form.get("zip") ?? ""),
city: String(form.get("city") ?? ""),
country: String(form.get("country") ?? ""),
companyName: String(form.get("companyName") ?? "") || undefined,
vatId: String(form.get("vatId") ?? "") || undefined,
hasDifferentShippingAddress,
shippingFirstName: hasDifferentShippingAddress ? String(form.get("shippingFirstName") ?? "") : undefined,
shippingLastName: hasDifferentShippingAddress ? String(form.get("shippingLastName") ?? "") : undefined,
shippingCompanyName: hasDifferentShippingAddress ? String(form.get("shippingCompanyName") ?? "") || undefined : undefined,
shippingDeliveryMethod: hasDifferentShippingAddress ? shippingDeliveryMethod : undefined,
shippingStreet: hasDifferentShippingAddress ? String(form.get("shippingStreet") ?? "") || undefined : undefined,
shippingPackstationNumber: hasDifferentShippingAddress
? String(form.get("shippingPackstationNumber") ?? "") || undefined
: undefined,
shippingPostNumber: hasDifferentShippingAddress ? String(form.get("shippingPostNumber") ?? "") || undefined : undefined,
shippingZip: hasDifferentShippingAddress ? String(form.get("shippingZip") ?? "") : undefined,
shippingCity: hasDifferentShippingAddress ? String(form.get("shippingCity") ?? "") : undefined,
shippingCountry: hasDifferentShippingAddress ? String(form.get("shippingCountry") ?? "") : undefined,
shippingContactEmail: hasDifferentShippingAddress ? String(form.get("shippingContactEmail") ?? "") || undefined : undefined,
shippingContactPhone: hasDifferentShippingAddress ? String(form.get("shippingContactPhone") ?? "") || undefined : undefined,
};
try {
@@ -91,6 +111,11 @@ export function ProfileForm({
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
{/* Explicit heading, matching /checkout's own "1. Rechnungsadresse"
card title — without it, this section and the "Lieferadresse"
one further down read as one undifferentiated form instead of
two distinct addresses. */}
<p className="font-semibold text-body-sm text-text-primary">Rechnungsadresse</p>
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="Vorname" name="firstName" type="text" defaultValue={profile.firstName} required />
<Field label="Nachname" name="lastName" type="text" defaultValue={profile.lastName} required />
@@ -112,43 +137,18 @@ export function ProfileForm({
/>
</div>
<div className="w-full flex flex-col gap-2 items-start">
<span className="text-label text-text-muted">Lieferart</span>
<div className="flex w-full max-w-sm rounded-sm border border-border overflow-hidden">
<button
type="button"
onClick={() => setDeliveryMethod("address")}
aria-pressed={deliveryMethod === "address"}
className={`flex-1 py-3 text-body-sm font-bold transition-colors ${deliveryMethod === "address" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
>
Lieferadresse
</button>
<button
type="button"
onClick={() => setDeliveryMethod("packstation")}
aria-pressed={deliveryMethod === "packstation"}
className={`flex-1 py-3 text-body-sm font-bold border-l border-border transition-colors ${deliveryMethod === "packstation" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
>
Packstation
</button>
</div>
</div>
{deliveryMethod === "address" ? (
<Field
label="Straße und Hausnummer"
name="street"
type="text"
defaultValue={profile.street ?? ""}
required
wrapperClassName="w-full"
/>
) : (
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="Packstationnummer" name="packstationNumber" type="text" defaultValue={profile.packstationNumber ?? ""} required />
<Field label="Postnummer" name="postNumber" type="text" defaultValue={profile.postNumber ?? ""} required />
</div>
)}
{/* Always a plain street address, no Lieferart/Packstation toggle —
matches /checkout's own Rechnungsadresse card exactly (an
invoice needs a real postal address). Packstation is only ever
offered below, in the optional "Lieferadresse" section. */}
<Field
label="Straße und Hausnummer"
name="street"
type="text"
defaultValue={profile.street ?? ""}
required
wrapperClassName="w-full"
/>
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="PLZ" name="zip" type="text" defaultValue={profile.zip ?? ""} required />
@@ -164,6 +164,111 @@ export function ProfileForm({
</select>
</label>
<div className="h-px bg-border w-full" />
<label className="flex gap-3 items-start w-full cursor-pointer">
<input
type="checkbox"
checked={hasDifferentShippingAddress}
onChange={(e) => setHasDifferentShippingAddress(e.target.checked)}
className="size-5 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
/>
<span className="text-body-sm text-text-primary">
Abweichende Lieferadresse hinterlegen wird beim Checkout vorgeschlagen, sobald dort "Abweichende Lieferadresse" aktiviert wird
</span>
</label>
{hasDifferentShippingAddress && (
<div className="flex flex-col gap-4 items-start w-full">
<p className="font-semibold text-body-sm text-text-primary">Lieferadresse</p>
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="Vorname" name="shippingFirstName" type="text" defaultValue={profile.shippingFirstName ?? ""} required />
<Field label="Nachname" name="shippingLastName" type="text" defaultValue={profile.shippingLastName ?? ""} required />
</div>
<Field
label="Firma (optional)"
name="shippingCompanyName"
type="text"
defaultValue={profile.shippingCompanyName ?? ""}
wrapperClassName="w-full"
/>
<div className="w-full flex flex-col gap-2 items-start">
<span className="text-label text-text-muted">Lieferart</span>
<div className="flex w-full max-w-sm rounded-sm border border-border overflow-hidden">
<button
type="button"
onClick={() => setShippingDeliveryMethod("address")}
aria-pressed={shippingDeliveryMethod === "address"}
className={`flex-1 py-3 text-body-sm font-bold transition-colors ${shippingDeliveryMethod === "address" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
>
Lieferadresse
</button>
<button
type="button"
onClick={() => setShippingDeliveryMethod("packstation")}
aria-pressed={shippingDeliveryMethod === "packstation"}
className={`flex-1 py-3 text-body-sm font-bold border-l border-border transition-colors ${shippingDeliveryMethod === "packstation" ? "bg-brand text-text-primary" : "text-text-muted hover:text-text-primary"}`}
>
Packstation
</button>
</div>
</div>
{shippingDeliveryMethod === "address" ? (
<Field
label="Straße und Hausnummer"
name="shippingStreet"
type="text"
defaultValue={profile.shippingStreet ?? ""}
required
wrapperClassName="w-full"
/>
) : (
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field
label="Packstationnummer"
name="shippingPackstationNumber"
type="text"
defaultValue={profile.shippingPackstationNumber ?? ""}
required
/>
<Field label="Postnummer" name="shippingPostNumber" type="text" defaultValue={profile.shippingPostNumber ?? ""} required />
</div>
)}
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="PLZ" name="shippingZip" type="text" defaultValue={profile.shippingZip ?? ""} required />
<Field label="Ort" name="shippingCity" type="text" defaultValue={profile.shippingCity ?? ""} required />
</div>
<label className="flex flex-col gap-2 items-start w-full sm:w-1/2">
<span className="text-label text-text-muted">Land</span>
<select name="shippingCountry" defaultValue={profile.shippingCountry ?? "Deutschland"} required className={`${inputClass} bg-bg-base`}>
{shippingCountries.map((c) => (
<option key={c.name}>{c.name}</option>
))}
</select>
</label>
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field
label="Kontakt-E-Mail (optional)"
name="shippingContactEmail"
type="email"
defaultValue={profile.shippingContactEmail ?? ""}
/>
<Field
label="Telefonnummer (optional)"
name="shippingContactPhone"
type="tel"
defaultValue={profile.shippingContactPhone ?? ""}
/>
</div>
<p className="text-label text-text-muted">Werden nur dem Versanddienstleister übergeben, z. B. für Lieferbenachrichtigungen.</p>
</div>
)}
{error && <p className="text-label text-red-600">{error}</p>}
{success && <p className="text-label text-success">Gespeichert.</p>}
@@ -0,0 +1,127 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Reveal } from "../../../components/Reveal";
import { mergeServerCartIntoLocal } from "../../../lib/cart";
import { dispatchAuthChanged } from "../../../lib/auth";
// Standalone registration, independent of checkout — until the Wishlist
// shipped, the only way to get a customer account was the inline
// registration step inside checkout (see api/checkout/route.ts), which
// made sense when an account only ever existed to hold an order. That
// stopped being true the moment a shopper could want an account just to
// save products to a Wishlist without buying anything yet.
export function RegisterForm() {
const router = useRouter();
const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect") || "/konto/bestellungen";
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch("/api/account/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ firstName, lastName, email, password }),
});
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Registrierung fehlgeschlagen.");
setLoading(false);
return;
}
await mergeServerCartIntoLocal();
dispatchAuthChanged();
router.push(redirectTo);
router.refresh();
} catch {
setError("Registrierung ist gerade nicht möglich.");
setLoading(false);
}
}
return (
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-20 px-[var(--layout-padding-x)] w-full max-w-[26rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Konto anlegen
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
<div className="flex gap-4 w-full">
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Vorname</span>
<input
type="text"
required
autoComplete="given-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
/>
</label>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Nachname</span>
<input
type="text"
required
autoComplete="family-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
/>
</label>
</div>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">E-Mail-Adresse</span>
<input
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
/>
</label>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Passwort</span>
<input
type="password"
required
minLength={8}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
/>
</label>
{error && <p className="text-label text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className={`w-full flex items-center justify-center py-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary ${loading ? "opacity-70 pointer-events-none" : ""}`}
>
{loading ? "Einen Moment…" : "Konto anlegen"}
</button>
</form>
<p className="text-body-sm text-text-muted">
Schon ein Konto?{" "}
<Link
href={`/konto/login${redirectTo !== "/konto/bestellungen" ? `?redirect=${encodeURIComponent(redirectTo)}` : ""}`}
className="underline hover:text-brand transition-colors"
>
Einloggen
</Link>
.
</p>
</Reveal>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { RegisterForm } from "./components/RegisterForm";
import { Footer } from "../../components/Footer";
// robots: noindex — account area, same reasoning as /konto/login.
export const metadata: Metadata = {
title: "Konto anlegen",
description: "Lege ein Konto bei einfach produktiv an.",
robots: {
index: false,
follow: true,
},
};
export default function KontoRegistrierenPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
{/* Suspense required — RegisterForm uses useSearchParams() (?redirect=),
same reasoning as /konto/login's own page. */}
<Suspense fallback={null}>
<RegisterForm />
</Suspense>
</main>
<Footer />
</>
);
}
+18 -3
View File
@@ -4,7 +4,8 @@ import "./globals.css";
import { Navbar } from "./components/Navbar";
import { CartFlyProvider } from "./components/CartFly";
import { CartSync } from "./components/CartSync";
import { getProducts, getSeoSettings } from "./lib/payload";
import { getProducts, getSeoSettings, getCompanySettings, getWishlistEnabled, getSearchEnabled } from "./lib/payload";
import { buildOrganizationSchema } from "./lib/structuredData";
const inter = Inter({
variable: "--font-inter",
@@ -66,8 +67,21 @@ 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, wishlistEnabled, searchEnabled] = await Promise.all([
getProducts(),
getCompanySettings(),
getWishlistEnabled(),
getSearchEnabled(),
]);
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,9 +89,10 @@ 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} />
<Navbar singleActiveProduct={singleActiveProduct} wishlistEnabled={wishlistEnabled} searchEnabled={searchEnabled} />
{children}
</CartFlyProvider>
</body>
@@ -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">
@@ -18,12 +18,12 @@ export const metadata: Metadata = {
title,
description,
alternates: {
canonical: "/challenge",
canonical: "/lebensuhr",
},
openGraph: {
title,
description,
url: "/challenge",
url: "/lebensuhr",
images: ["/blog-featured.jpg"],
},
twitter: {
@@ -152,7 +152,7 @@ export default async function ChallengePage() {
<span></span>
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">Werkzeuge</Link>
<span></span>
<span className="text-text-primary">7-Tage-Challenge</span>
<span className="text-text-primary">Lebensuhr</span>
</p>
{/* Everything else centered in the remaining vertical space,
+2
View File
@@ -6,10 +6,12 @@ const product = (overrides: Partial<RawProduct> = {}): RawProduct => ({
id: 1,
slug: "starter-set",
name: "Starter-Set",
sku: null,
price: 29.9,
active: true,
image: null,
taxRatePercent: null,
noShippingCost: false,
bundleItems: null,
variants: null,
trackInventory: false,
+3
View File
@@ -4,6 +4,7 @@ import type { Product } from "../payload";
const product = (overrides: Partial<Product> = {}): Product => ({
id: "todo-karten",
numericId: 1,
name: "ToDo-Karten",
description: "",
price: 12.9,
@@ -17,11 +18,13 @@ const product = (overrides: Partial<Product> = {}): Product => ({
spotlightHeadline: null,
spotlightText: null,
spotlightImage: null,
spotlightShowWishlist: false,
variants: [],
outOfStock: false,
lowStock: false,
maxQty: null,
taxRatePercent: null,
noShippingCost: false,
...overrides,
});
+4 -4
View File
@@ -40,10 +40,10 @@ export async function sendVerificationEmail(to: string, firstName: string, token
const url = `https://einfach-produktiv.mk360.de/api/account/verify-email?token=${token}`;
const seller = await getSellerForInvoice();
await transport.sendMail({
// See orderEmail.ts's own comment on why only the display name is
// dynamic — the address stays admin@mk360.de until sellerEmail's domain
// is confirmed SPF-authorized on the Hostinger account.
from: `"${seller?.sellerName ?? "einfach produktiv"}" <admin@mk360.de>`,
// See orderEmail.ts's own comment — From now matches sellerEmail
// itself since the SMTP account moved to a mailbox on that domain,
// and emailFromName/emailFromAddress let an admin override both.
from: `"${seller?.emailFromName || seller?.sellerName || "Björn"}" <${seller?.emailFromAddress || seller?.sellerEmail || "hallo@einfach-produktiv.com"}>`,
replyTo: seller?.sellerEmail || undefined,
to,
subject: "Bitte bestätige deine E-Mail-Adresse",
+33 -7
View File
@@ -15,11 +15,36 @@
// Switched 2026-07-25 per explicit request once the confirmation-email
// template existed to point templateId at.
const BREVO_DOUBLE_OPTIN_URL = "https://api.brevo.com/v3/contacts/doubleOptinConfirmation";
const BREVO_CONTACTS_URL = "https://api.brevo.com/v3/contacts";
export type BrevoSyncResult = { ok: true } | { ok: false; reason: string };
export type BrevoSyncResult = { ok: true; alreadySubscribed?: boolean } | { ok: false; reason: string };
export type NewsletterOptInSource = "checkout" | "newsletter-page" | "newsletter-modal" | "newsletter-hero" | "challenge";
// Checked before calling doubleOptinConfirmation — that endpoint gives
// no way to tell "brand new signup" apart from "already confirmed,
// resending the same mail again" (verified directly: calling it a
// second time for an already-subscribed contact still returns a plain
// 201, same as the first time). `listIds` on a Brevo contact is only
// populated once double opt-in actually confirms (never for a merely
// *requested*, still-pending one), so its presence here is a reliable
// "already subscribed to this list" signal. Fails open on any error —
// this check is a UX nicety (skip an unnecessary resend, show a
// friendlier message), never a reason to block a real signup attempt.
async function isAlreadySubscribed(email: string, apiKey: string, listId: string): Promise<boolean> {
try {
const res = await fetch(`${BREVO_CONTACTS_URL}/${encodeURIComponent(email)}`, {
headers: { "api-key": apiKey },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return false; // 404 (never signed up before) or any transient error
const contact: { listIds?: number[] } = await res.json();
return (contact.listIds ?? []).includes(Number(listId));
} catch {
return false;
}
}
// `source` becomes a Brevo contact attribute so campaigns/segments can
// tell a checkout opt-in apart from the standalone signup forms without
// needing separate lists.
@@ -33,6 +58,11 @@ export async function upsertNewsletterContact(
if (!apiKey || !listId || !templateId) {
return { ok: false, reason: "BREVO_API_KEY/BREVO_LIST_ID/BREVO_DOUBLE_OPTIN_TEMPLATE_ID nicht konfiguriert." };
}
if (await isAlreadySubscribed(email, apiKey, listId)) {
return { ok: true, alreadySubscribed: true };
}
const redirectionUrl = process.env.BREVO_DOI_REDIRECT_URL || "https://einfach-produktiv.mk360.de/newsletter-confirmed";
try {
@@ -52,12 +82,8 @@ export async function upsertNewsletterContact(
signal: AbortSignal.timeout(8000),
});
// 201 Created is this endpoint's success status (unlike the plain
// contacts upsert this replaced, which used 204). A contact who's
// already confirmed-and-subscribed re-submitting the form is not
// treated as an error either — Brevo resends the confirmation email
// in that case rather than erroring, which is an acceptable no-op
// resend from this app's point of view (matches the previous
// endpoint's "always succeeds for an existing contact too" behavior).
// contacts upsert this replaced, which used 204). The already-
// subscribed case is handled above, before this call ever fires.
if (res.ok || res.status === 201) return { ok: true };
const body = await res.json().catch(() => null);
return { ok: false, reason: body?.message ?? `Brevo antwortete mit ${res.status}` };
+12 -5
View File
@@ -18,7 +18,8 @@ function readCart(): CartItem[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(CART_KEY);
return raw ? JSON.parse(raw) : [];
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
@@ -90,7 +91,8 @@ export function getCart(): CartItem[] {
if (raw === cachedRaw) return cachedItems;
cachedRaw = raw;
try {
cachedItems = raw ? JSON.parse(raw) : EMPTY_CART;
const parsed = raw ? JSON.parse(raw) : EMPTY_CART;
cachedItems = Array.isArray(parsed) ? parsed : EMPTY_CART;
} catch {
cachedItems = EMPTY_CART;
}
@@ -125,9 +127,14 @@ export function useCart(): CartItem[] {
// Called right after a successful login (LoginForm.tsx, CheckoutContent.tsx's
// inline login toggle) — folds whatever was saved server-side into the
// local cart by quantity (addToCart adds to an existing line rather than
// overwriting it), so items added before logging in aren't lost. CartSync
// then picks up the resulting change and pushes the merged cart back to
// the server on its own, closing the loop without a separate save call here.
// overwriting it), so items added as a guest before logging in aren't lost.
// This only stays correct because LogoutButton.tsx clears the local cart on
// logout — the local cart is always either empty (no guest additions since
// the last logout) or holds only genuinely new guest-session items, never a
// stale copy of what's already in the server cart, so this add never
// double-counts. CartSync then picks up the resulting change and pushes the
// merged cart back to the server on its own, closing the loop without a
// separate save call here.
export async function mergeServerCartIntoLocal(): Promise<void> {
try {
const res = await fetch("/api/account/cart");
+10
View File
@@ -37,6 +37,16 @@ export function computeSubtotal(items: CartLine[]): number {
return items.reduce((sum, { entry, product }) => sum + entry.qty * effectivePrice(entry, product), 0);
}
// A cart only needs a shipping line at all if at least one item doesn't
// opt out via Products.noShippingCost (e.g. a purely digital download) —
// mirrors api/checkout/route.ts's own hasShippableItem check, which is
// the actual charged amount; this is only the storefront's estimate/
// display before that. A single non-exempt item still triggers normal
// shipping for the whole cart, this never partially discounts it.
export function cartHasShippableItem(items: CartLine[]): boolean {
return items.some(({ product }) => !product.noShippingCost);
}
export type CartTotals = {
subtotal: number;
/** compareAtPrice-based per-product savings — already excluded from
+3
View File
@@ -36,6 +36,9 @@ export type CheckoutDraft = {
shippingZip: string;
shippingCity: string;
shippingCountry: string;
shippingCompanyName: string;
shippingContactEmail: string;
shippingContactPhone: string;
newsletterOptIn: boolean;
shippingMethodId: number | null;
paymentMethodId: number | null;
+248 -5
View File
@@ -203,6 +203,23 @@ export type CustomerAddress = {
// /checkout's Firma/USt-IdNr. fields for a returning customer.
companyName: string | null;
vatId: string | null;
// Optional second/shipping address — see Customers.ts's "Lieferadresse"
// tab. Prefills /checkout's "Abweichende Lieferadresse" section once
// hasDifferentShippingAddress is set here; still fully overwritable per
// order (Orders keeps its own shipping* snapshot regardless).
hasDifferentShippingAddress: boolean;
shippingFirstName: string | null;
shippingLastName: string | null;
shippingCompanyName: string | null;
shippingDeliveryMethod: "address" | "packstation" | null;
shippingStreet: string | null;
shippingPackstationNumber: string | null;
shippingPostNumber: string | null;
shippingZip: string | null;
shippingCity: string | null;
shippingCountry: string | null;
shippingContactEmail: string | null;
shippingContactPhone: string | null;
};
export type CustomerProfile = CustomerSummary & CustomerAddress;
@@ -223,6 +240,19 @@ type PayloadCustomerMe = {
country: string | null;
companyName: string | null;
vatId: string | null;
hasDifferentShippingAddress: boolean | null;
shippingFirstName: string | null;
shippingLastName: string | null;
shippingCompanyName: string | null;
shippingDeliveryMethod: "address" | "packstation" | null;
shippingStreet: string | null;
shippingPackstationNumber: string | null;
shippingPostNumber: string | null;
shippingZip: string | null;
shippingCity: string | null;
shippingCountry: string | null;
shippingContactEmail: string | null;
shippingContactPhone: string | null;
cart: { product: number; productSlug: string; quantity: number; variantName: string | null }[] | null;
};
@@ -251,6 +281,19 @@ export async function getCustomerProfile(token: string): Promise<CustomerProfile
country: u.country,
companyName: u.companyName,
vatId: u.vatId,
hasDifferentShippingAddress: Boolean(u.hasDifferentShippingAddress),
shippingFirstName: u.shippingFirstName,
shippingLastName: u.shippingLastName,
shippingCompanyName: u.shippingCompanyName,
shippingDeliveryMethod: u.shippingDeliveryMethod,
shippingStreet: u.shippingStreet,
shippingPackstationNumber: u.shippingPackstationNumber,
shippingPostNumber: u.shippingPostNumber,
shippingZip: u.shippingZip,
shippingCity: u.shippingCity,
shippingCountry: u.shippingCountry,
shippingContactEmail: u.shippingContactEmail,
shippingContactPhone: u.shippingContactPhone,
};
}
@@ -269,6 +312,19 @@ export async function updateCustomerProfile(
country: string;
companyName?: string;
vatId?: string;
hasDifferentShippingAddress?: boolean;
shippingFirstName?: string;
shippingLastName?: string;
shippingCompanyName?: string;
shippingDeliveryMethod?: "address" | "packstation";
shippingStreet?: string;
shippingPackstationNumber?: string;
shippingPostNumber?: string;
shippingZip?: string;
shippingCity?: string;
shippingCountry?: string;
shippingContactEmail?: string;
shippingContactPhone?: string;
},
): Promise<{ ok: true } | { ok: false; reason: string }> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
@@ -301,6 +357,71 @@ export async function changeCustomerPassword(
return { ok: true };
}
export type WishlistItem = {
id: number;
productId: number;
variant: string;
};
// `variant` empty string, not undefined — matches WishlistItems.ts's own
// defaultValue: '' so the (customer, product, variant) unique index
// actually catches a duplicate add for a variant-less product too.
export async function getWishlist(token: string, customerId: number): Promise<WishlistItem[]> {
const params = new URLSearchParams({
"where[customer][equals]": String(customerId),
depth: "0",
limit: "200",
sort: "-createdAt",
});
const res = await fetch(`${PAYLOAD_URL}/api/wishlist-items?${params}`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return [];
const data: { docs?: { id: number; product: number; variant?: string }[] } = await res.json();
return (data.docs ?? []).map((doc) => ({ id: doc.id, productId: doc.product, variant: doc.variant ?? "" }));
}
// Toggles a single (product, variant) — tries to create first; a 400 here
// means the unique (customer, product, variant) index rejected it because
// it already exists, so this falls back to finding + deleting that row
// instead. Avoids a separate "is it already wishlisted" read before every
// toggle (the common case, adding something new, only needs one request).
export async function toggleWishlistItem(
token: string,
productId: number,
variant: string,
): Promise<{ ok: true; wishlisted: boolean } | { ok: false }> {
const createRes = await fetch(`${PAYLOAD_URL}/api/wishlist-items`, {
method: "POST",
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ product: productId, variant }),
});
if (createRes.ok) return { ok: true, wishlisted: true };
const findParams = new URLSearchParams({
"where[product][equals]": String(productId),
"where[variant][equals]": variant,
depth: "0",
limit: "1",
});
const findRes = await fetch(`${PAYLOAD_URL}/api/wishlist-items?${findParams}`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!findRes.ok) return { ok: false };
const found: { docs?: { id: number }[] } = await findRes.json();
const existingId = found.docs?.[0]?.id;
if (!existingId) return { ok: false };
const deleteRes = await fetch(`${PAYLOAD_URL}/api/wishlist-items/${existingId}`, {
method: "DELETE",
headers: { Authorization: `JWT ${token}` },
});
if (!deleteRes.ok) return { ok: false };
return { ok: true, wishlisted: false };
}
// Called from app/api/account/verify-email/route.ts — no customer session
// exists at this point (cold click from an email client), so this
// authenticates as the service instead (see SERVICE_SECRET above).
@@ -399,7 +520,14 @@ export const ORDER_STATUS_LABEL: Record<string, string> = {
// decide which button, if any, to show).
export function customerOrderAction(status: string): "cancel" | "request-return" | null {
if (status === "received") return "cancel";
if (status === "shipped" || status === "delivered") return "request-return";
// Only once actually delivered — a (partial) return before the package
// even arrived doesn't make sense yet. The backend's own
// CUSTOMER_ALLOWED_TRANSITIONS still technically permits 'shipped' →
// 'return_requested' too (an extensive existing test suite is built
// around that as its base fixture) — this only narrows what the UI
// itself offers, a stricter subset of what the backend already allows,
// not a security boundary being loosened.
if (status === "delivered") return "request-return";
return null;
}
@@ -408,6 +536,7 @@ export type CustomerOrder = {
createdAt: string;
total: number;
status: string;
paymentStatus: "not_applicable" | "pending" | "paid" | "failed" | "refunded" | "partially_refunded";
itemCount: number;
/** Raw product relationship ids, in item order — depth=0 keeps them as
* plain numbers, not populated objects. Callers resolve these to image
@@ -416,9 +545,79 @@ 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",
};
}
// "Offen" in the account UI's PaymentStatusBadge covers two distinct
// backend values (an unconfirmed Stripe payment vs. an Überweisung order
// awaiting manual reconciliation) — the filter chip mirrors that same
// grouping rather than exposing the internal distinction as two options.
const OPEN_PAYMENT_STATUSES = ["pending", "not_applicable"] as const;
export type CustomerOrderFilters = {
status?: string;
paymentStatus?: string;
/** Calendar year as a string, e.g. "2026" — matches createdAt within
* [Jan 1, Jan 1 of next year). */
year?: string;
};
function customerOrderFilterQuery(filters: CustomerOrderFilters | undefined, whereIndex: number): Record<string, string> {
if (!filters) return {};
const params: Record<string, string> = {};
let i = whereIndex;
if (filters.status) {
params[`where[and][${i}][status][equals]`] = filters.status;
i += 1;
}
if (filters.paymentStatus) {
if (filters.paymentStatus === "open") {
OPEN_PAYMENT_STATUSES.forEach((value, j) => {
params[`where[and][${i}][or][${j}][paymentStatus][equals]`] = value;
});
} else {
params[`where[and][${i}][paymentStatus][equals]`] = filters.paymentStatus;
}
i += 1;
}
if (filters.year && /^\d{4}$/.test(filters.year)) {
const year = Number(filters.year);
params[`where[and][${i}][createdAt][greater_than_equal]`] = new Date(Date.UTC(year, 0, 1)).toISOString();
params[`where[and][${i}][createdAt][less_than]`] = new Date(Date.UTC(year + 1, 0, 1)).toISOString();
}
return params;
}
// `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,
filters?: CustomerOrderFilters,
): Promise<CustomerOrder[]> {
const params = new URLSearchParams({
"where[customer][equals]": String(customerId),
"where[and][0][customer][equals]": String(customerId),
...(excludeFailedPaymentAttempts ? excludeFailedPaymentAttemptsQuery() : {}),
...customerOrderFilterQuery(filters, excludeFailedPaymentAttempts ? 2 : 1),
sort: "-createdAt",
depth: "0",
limit: "50",
@@ -429,20 +628,42 @@ export async function getCustomerOrders(token: string, customerId: number): Prom
});
if (!res.ok) return [];
const data: {
docs?: { orderNumber: string; createdAt: string; total: number; status: string; items: { product: number }[] }[];
docs?: {
orderNumber: string;
createdAt: string;
total: number;
status: string;
paymentStatus: CustomerOrder["paymentStatus"];
items: { product: number }[];
}[];
} = await res.json();
return (data.docs ?? []).map((doc) => ({
orderNumber: doc.orderNumber,
createdAt: doc.createdAt,
total: doc.total,
status: doc.status,
paymentStatus: doc.paymentStatus,
itemCount: doc.items.length,
productIds: doc.items.map((item) => item.product),
}));
}
// All years that have at least one (non-filtered-out) order for this
// customer — powers the year filter's option list without hardcoding a
// range. Cheap: reuses the same excludeFailedPaymentAttempts query, no
// separate collection/aggregation endpoint needed for this order volume.
export async function getCustomerOrderYears(token: string, customerId: number): Promise<string[]> {
const orders = await getCustomerOrders(token, customerId, true);
const years = new Set(orders.map((o) => new Date(o.createdAt).getUTCFullYear().toString()));
return Array.from(years).sort((a, b) => Number(b) - Number(a));
}
export type CustomerOrderDetail = CustomerOrder & {
id: number;
// 'manual' (Überweisung) vs 'stripe' (Kreditkarte/PayPal) — see
// api/account/orders/[orderNumber]/switch-to-stripe/route.ts, which only
// offers a payment-method switch for a still-'manual' order.
paymentProvider: "manual" | "stripe";
// 'not_applicable' for Überweisung orders (never gated); see
// spicy-leaping-pizza.md §1 — read by /api/checkout/status for the
// post-Stripe-redirect polling page.
@@ -453,6 +674,11 @@ export type CustomerOrderDetail = CustomerOrder & {
correctionInvoiceIssuedAt: string | null;
carrier: string | null;
trackingNumber: string | null;
// Raw media id, not populated — this fetch stays depth=0 (see this
// function's own comment on why), so the order-detail page resolves the
// actual download URL itself via a separate media lookup when present.
dhlReturnLabelMedia: number | null;
dhlReturnTrackingNumber: string | null;
customerFirstName: string;
customerLastName: string;
customerEmail: string;
@@ -471,6 +697,7 @@ export type CustomerOrderDetail = CustomerOrder & {
hasDifferentShippingAddress: boolean;
shippingFirstName: string | null;
shippingLastName: string | null;
shippingCompanyName: string | null;
shippingDeliveryMethod: "address" | "packstation" | null;
shippingStreet: string | null;
shippingPackstationNumber: string | null;
@@ -478,6 +705,8 @@ export type CustomerOrderDetail = CustomerOrder & {
shippingZip: string | null;
shippingCity: string | null;
shippingCountry: string | null;
shippingContactEmail: string | null;
shippingContactPhone: string | null;
subtotal: number;
shippingCost: number;
shippingMethodTitle: string;
@@ -497,6 +726,7 @@ export type CustomerOrderItem = {
bundleContents: string | null;
variantName: string | null;
returnQuantity: number;
sku: string | null;
};
// Access control (Orders.ts) already scopes a customer's own JWT to only
@@ -508,10 +738,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",
});
+59 -1
View File
@@ -66,6 +66,32 @@ function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Vorkasse (Überweisung/manual) instruction — the invoice PDF already
// shows this same information (see @einfach-produktiv/invoicing's own
// unpaidNoticeText), but a customer often only glances at the email body
// itself, not the attached PDF, so it's repeated here in plain text too.
// Own full-width block, margin-top matching the other section gaps in
// this template (16px) — not squeezed into the narrow Gesamtsumme table
// like an initial draft of the invoice version was before that got
// widened per feedback.
function vorkasseNotice(orderNumber: string, seller: CompanySettings | null, hasOnlinePaymentOption: boolean): string {
const bankLine = seller && (seller.iban || seller.bic)
? [seller.bankName, seller.iban && `IBAN ${seller.iban}`, seller.bic && `BIC ${seller.bic}`].filter(Boolean).join(" · ")
: null;
return `
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:20px;background:${BG_MUTED};border-radius:8px;padding:16px 20px;">
<tr>
<td style="font-size:13px;line-height:1.6;color:${TEXT_MUTED};text-align:center;">
<p style="margin:0 0 8px;">Bitte überweise den Rechnungsbetrag unter Angabe der Bestellnummer ${escapeHtml(orderNumber)} auf ${bankLine ? "folgende Bankverbindung:" : "die dir genannte Bankverbindung."}</p>
${bankLine ? `<p style="margin:0 0 12px;font-weight:700;color:${TEXT_PRIMARY};">${escapeHtml(bankLine)}</p>` : ""}
<p style="margin:0;">Deine Bestellung wird nach Zahlungseingang bearbeitet (in der Regel innerhalb von 12 Werktagen).</p>
${hasOnlinePaymentOption ? `<p style="margin:8px 0 0;">Zahlungsart geändert? Solange die Überweisung noch nicht bei uns eingegangen ist, kannst du in deinem Konto jederzeit auf Kreditkarte/PayPal umsteigen.</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
@@ -171,6 +197,7 @@ export type OrderConfirmationItem = {
bundleContents?: string | null;
variantName?: string | null;
taxRatePercent: number;
sku?: string | null;
};
export type OrderConfirmationData = {
orderNumber: string;
@@ -181,6 +208,20 @@ 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;
// Only meaningful when isManualPayment is true — whether at least one
// Stripe-backed payment method is currently active, so the Vorkasse
// notice can mention the option to switch instead of promising it
// unconditionally. Set by the checkout route from the same
// getPaymentMethods() call it already makes.
hasOnlinePaymentOption?: boolean;
};
export const SAMPLE_ORDER: OrderConfirmationData = {
@@ -202,6 +243,18 @@ export const SAMPLE_ORDER: OrderConfirmationData = {
discountAmount: 5,
discountCode: "WILLKOMMEN10",
total: 37.7,
isManualPayment: false,
};
// Second preview fixture — demonstrates the Vorkasse/Überweisung branch
// (vorkasseNotice(), incl. the "switch to Kreditkarte/PayPal" mention)
// that SAMPLE_ORDER's own isManualPayment: false never shows. Used by
// LiveEmailPreviewClient.tsx's toggle, not by any real send — a real
// order-confirmation email always computes both flags live per order.
export const SAMPLE_ORDER_MANUAL: OrderConfirmationData = {
...SAMPLE_ORDER,
isManualPayment: true,
hasOnlinePaymentOption: true,
};
export function renderOrderConfirmationHtml(template: EmailTemplateContent, order: OrderConfirmationData, seller: CompanySettings | null): string {
@@ -215,7 +268,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
: `<div style="width:44px;height:44px;border-radius:6px;background:${BG_MUTED};"></div>`
}
</td>
<td style="padding:10px 0 10px 12px;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)}${item.variantName ? ` (${escapeHtml(item.variantName)})` : ""} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span>${item.bundleContents ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">${escapeHtml(item.bundleContents)}</span>` : ""}</td>
<td style="padding:10px 0 10px 12px;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)}${item.variantName ? ` (${escapeHtml(item.variantName)})` : ""} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span>${item.bundleContents ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">${escapeHtml(item.bundleContents)}</span>` : ""}${item.sku ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">Art.-Nr. ${escapeHtml(item.sku)}</span>` : ""}</td>
<td style="padding:10px 0;border-bottom:1px solid ${BORDER};text-align:right;white-space:nowrap;font-size:14px;color:${TEXT_PRIMARY};">${formatPrice(item.quantity * item.unitPrice)}</td>
</tr>`,
)
@@ -263,6 +316,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
</tr>
${taxRows}
</table>
${order.isManualPayment ? vorkasseNotice(order.orderNumber, seller, Boolean(order.hasOnlinePaymentOption)) : ""}
`;
return emailShell("✓", escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
@@ -278,6 +332,10 @@ export const ORDER_STATUS_EMAIL_ICON: Record<string, string> = {
"order-cancelled": "✕",
"order-return-requested": "↩",
"order-returned": "✓",
"order-tracking-added": "📦",
"order-tracking-corrected": "📦",
"order-delivered": "🎉",
"payment-method-switched": "💳",
};
export function renderOrderStatusHtml(
+1 -1
View File
@@ -1,4 +1,4 @@
const MIN_VW = 768;
const MIN_VW = 640;
const MAX_VW = 1440;
/**
+102 -21
View File
@@ -1,6 +1,6 @@
import { transport } from "./mailer";
import { getEmailTemplate } from "./payload";
import { renderOrderConfirmationHtml, type OrderConfirmationData } from "./emailTemplates";
import { renderOrderConfirmationHtml, renderOrderStatusHtml, type OrderConfirmationData } from "./emailTemplates";
import { generateInvoicePdf, getSellerForInvoice } from "./invoiceData";
import { sendCriticalAlert } from "./alertAdmin";
@@ -27,6 +27,7 @@ export type OrderConfirmationEmailData = OrderConfirmationData & {
hasDifferentShippingAddress?: boolean;
shippingFirstName?: string | null;
shippingLastName?: string | null;
shippingCompanyName?: string | null;
shippingDeliveryMethod?: "address" | "packstation" | null;
shippingStreet?: string | null;
shippingPackstationNumber?: string | null;
@@ -34,6 +35,8 @@ export type OrderConfirmationEmailData = OrderConfirmationData & {
shippingZip?: string | null;
shippingCity?: string | null;
shippingCountry?: string | null;
shippingContactEmail?: string | null;
shippingContactPhone?: string | null;
paymentMethodTitle: string;
};
@@ -52,18 +55,20 @@ export type OrderConfirmationEmailData = OrderConfirmationData & {
// its own try/catch and just sends without the attachment if generation
// 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")) ?? {
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.",
footerText: null,
};
const seller = await getSellerForInvoice();
const html = renderOrderConfirmationHtml(template, order, seller);
let attachments: { filename: string; content: Buffer }[] | undefined;
// Shared by sendOrderConfirmationEmail and sendPaymentSwitchedEmail — both
// need to (re)generate the exact same invoice PDF for the exact same
// order, just with a different email body wrapped around it. Regenerated
// fresh each time rather than cached anywhere, same "deterministic
// regeneration, not file storage" approach as the on-demand download
// routes — this also means a switched-payment send picks up the now
// up-to-date paymentMethodTitle, so the PDF's own "✓ Bereits beglichen"
// vs. Vorkasse-notice branch (see @einfach-produktiv/invoicing's
// isPaidImmediately()) reflects the real, current payment state even
// though invoiceNumber/invoiceIssuedAt never change.
async function buildInvoiceAttachment(
order: OrderConfirmationEmailData,
seller: Awaited<ReturnType<typeof getSellerForInvoice>>,
): Promise<{ filename: string; content: Buffer }[] | undefined> {
try {
const pdf = await generateInvoicePdf(
{
@@ -86,6 +91,7 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
hasDifferentShippingAddress: order.hasDifferentShippingAddress ?? false,
shippingFirstName: order.shippingFirstName,
shippingLastName: order.shippingLastName,
shippingCompanyName: order.shippingCompanyName,
shippingDeliveryMethod: order.shippingDeliveryMethod,
shippingStreet: order.shippingStreet,
shippingPackstationNumber: order.shippingPackstationNumber,
@@ -93,6 +99,8 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
shippingZip: order.shippingZip,
shippingCity: order.shippingCity,
shippingCountry: order.shippingCountry,
shippingContactEmail: order.shippingContactEmail,
shippingContactPhone: order.shippingContactPhone,
paymentMethodTitle: order.paymentMethodTitle,
items: order.items.map((i) => ({
productName: i.productName,
@@ -102,6 +110,7 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
bundleContents: i.bundleContents ?? null,
variantName: i.variantName ?? null,
imageUrl: i.imageUrl ?? null,
sku: i.sku ?? null,
})),
subtotal: order.subtotal,
shippingCost: order.shippingCost,
@@ -111,23 +120,95 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
},
seller,
);
if (pdf) attachments = [{ filename: `Rechnung-${order.invoiceNumber}.pdf`, content: pdf }];
else throw new Error("generateInvoicePdf returned null (missing invoice-settings?)");
if (!pdf) throw new Error("generateInvoicePdf returned null (missing invoice-settings?)");
return [{ filename: `Rechnung-${order.invoiceNumber}.pdf`, content: pdf }];
} catch (err) {
sendCriticalAlert("Rechnungs-PDF konnte nicht erzeugt werden", {
orderNumber: order.orderNumber,
invoiceNumber: order.invoiceNumber,
error: String(err),
});
return undefined;
}
}
export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailData, customerEmail: string): Promise<boolean> {
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.",
footerText: null,
};
const seller = await getSellerForInvoice();
const html = renderOrderConfirmationHtml(template, order, seller);
const attachments = await buildInvoiceAttachment(order, seller);
await transport.sendMail({
// Display name only, not the address — see the memory note on why the
// envelope stays admin@mk360.de (that's the domain the Hostinger SMTP
// account is actually authorized for; sellerEmail's domain isn't
// confirmed SPF-authorized on it yet). Reply-To is what actually routes
// a customer's reply to the seller, regardless of the From address.
from: `"${seller?.sellerName ?? "einfach produktiv"}" <admin@mk360.de>`,
// SPF confirmed 2026-07-29 for einfach-produktiv.com, and the SMTP
// account itself switched to a mailbox on that domain — see the
// backend's sellerInfo.ts buildFromHeader() comment for why both had
// to move together (Hostinger's relay rejects a From address the
// authenticated mailbox doesn't own). Reply-To stays set too, though
// now redundant with From itself pointing at sellerEmail.
// emailFromName/emailFromAddress (company-settings) are the
// admin-editable override, same fallback chain as the backend's
// buildFromHeader().
from: `"${seller?.emailFromName || seller?.sellerName || "Björn"}" <${seller?.emailFromAddress || seller?.sellerEmail || "hallo@einfach-produktiv.com"}>`,
replyTo: seller?.sellerEmail || undefined,
to: customerEmail,
subject: template.subject,
html,
attachments,
});
return true;
}
// Sent instead of sendOrderConfirmationEmail() when the confirmed payment
// came from switchPaymentToStripe.ts (an existing Überweisung order the
// customer moved to Kreditkarte/PayPal), not a fresh checkout — see
// confirmPaymentEmail.ts's own branch on order.paymentSwitchedAt. A second
// full "Vielen Dank für deine Bestellung!" email (with its own invoice PDF
// re-attached) would read as a brand-new purchase; this is a short,
// dedicated confirmation instead, same shape as the order-status emails
// (icon + admin-editable text + "Bestellung ansehen" button), no item
// table/invoice attachment — the invoice itself didn't change, only how
// it got paid.
export async function sendPaymentSwitchedEmail(order: OrderConfirmationEmailData, customerEmail: string): Promise<boolean> {
const fetchedTemplate = await getEmailTemplate("payment-method-switched");
if (fetchedTemplate && !fetchedTemplate.active) return false;
const template = fetchedTemplate ?? {
subject: "Erledigt! Deine Zahlung ist da 🎉",
heading: "Erledigt!",
bodyText: "Deine Zahlung ist gerade bei uns eingetrudelt — ab jetzt läuft alles automatisch weiter, du musst dich um nichts mehr kümmern. Deine aktualisierte Rechnung findest du im Anhang.",
footerText: null,
};
const seller = await getSellerForInvoice();
const html = renderOrderStatusHtml(
template,
"💳",
order.orderNumber,
`https://einfach-produktiv.mk360.de/konto/bestellungen/${encodeURIComponent(order.orderNumber)}`,
seller,
);
// Same invoiceNumber as always (never re-issued for a switch, see
// confirmPayment.ts), but paymentMethodTitle now reflects the actually-
// confirmed instrument — worth a fresh PDF, not the original attachment,
// since @einfach-produktiv/invoicing's own isPaidImmediately() check
// reads that title to decide "✓ Bereits beglichen" vs. the Vorkasse
// notice.
const attachments = await buildInvoiceAttachment(order, seller);
await transport.sendMail({
from: `"${seller?.emailFromName || seller?.sellerName || "Björn"}" <${seller?.emailFromAddress || seller?.sellerEmail || "hallo@einfach-produktiv.com"}>`,
replyTo: seller?.sellerEmail || undefined,
to: customerEmail,
subject: template.subject,
+22
View File
@@ -27,6 +27,11 @@ export type OrderItemInput = {
taxRatePercent: number;
bundleContents: string | null;
variantName: string | null;
// Resolved by the caller (api/checkout/route.ts): the variant's own sku
// if one was selected, else the product's sku, else null — same
// "variant overrides product" precedence as unitPrice/taxRatePercent
// elsewhere in this checkout flow.
sku: string | null;
};
export type CreateOrderInput = {
@@ -67,11 +72,23 @@ export type CreateOrderInput = {
shippingZip?: string;
shippingCity?: string;
shippingCountry?: string;
// Optional, mirrors companyName above — no shipping-side vatId (billing-
// only concept). Contact email/phone have no billing-side equivalent:
// they're handed to the shipping carrier, never used for customer
// communication.
shippingCompanyName?: string;
shippingContactEmail?: string;
shippingContactPhone?: string;
newsletterOptIn: boolean;
items: OrderItemInput[];
subtotal: number;
shippingCost: number;
shippingMethodTitle: string;
// Numeric ShippingMethod id, not the same as shippingMethodTitle's frozen
// text snapshot — lets pollCarrierTracking.ts resolve order → shipping
// method → carrier without a fragile title-text match. See Orders.ts's
// own comment on why both fields exist side by side.
shippingMethod: number;
paymentMethodTitle: string;
discountCode: string | null;
discountAmount: number;
@@ -140,6 +157,9 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
shippingZip: input.shippingZip,
shippingCity: input.shippingCity,
shippingCountry: input.shippingCountry,
shippingCompanyName: input.shippingCompanyName,
shippingContactEmail: input.shippingContactEmail,
shippingContactPhone: input.shippingContactPhone,
newsletterOptIn: input.newsletterOptIn,
items: input.items.map((i) => ({
product: i.productId,
@@ -149,10 +169,12 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
taxRatePercent: i.taxRatePercent,
bundleContents: i.bundleContents,
variantName: i.variantName,
sku: i.sku,
})),
subtotal: input.subtotal,
shippingCost: input.shippingCost,
shippingMethodTitle: input.shippingMethodTitle,
shippingMethod: input.shippingMethod,
paymentMethodTitle: input.paymentMethodTitle,
discountCode: input.discountCode,
discountAmount: input.discountAmount,
+198 -15
View File
@@ -21,7 +21,7 @@ export type BlogPost = {
id: number;
title: string;
slug: string;
category: string;
categories: string[];
readTime: number;
excerpt: string;
thumbnail: string | null;
@@ -33,7 +33,7 @@ type PayloadPost = {
id: number;
title: string;
slug: string;
category: { name: string } | number | null;
categories: ({ name: string } | number)[];
readTime: number;
excerpt: string;
thumbnail: { url: string } | number | null;
@@ -49,6 +49,11 @@ type PayloadPost = {
export async function getBlogPosts(limit = 3): Promise<BlogPost[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
// Draft/scheduled posts never appear publicly — same "filter, not
// access-control" pattern as Products.active. Live preview
// (LivePostContent.tsx) bypasses this entirely since it fetches the
// one specific document by id directly, not through this list.
"where[status][equals]": "published",
sort: "-featured,-publishedAt",
depth: "2",
limit: String(limit),
@@ -68,10 +73,9 @@ export async function getBlogPosts(limit = 3): Promise<BlogPost[]> {
id: post.id,
title: post.title,
slug: post.slug,
category:
typeof post.category === "object" && post.category
? post.category.name
: "",
categories: (post.categories ?? [])
.map((c) => (typeof c === "object" && c ? c.name : null))
.filter((name): name is string => Boolean(name)),
readTime: post.readTime,
excerpt: post.excerpt,
thumbnail:
@@ -120,8 +124,9 @@ export function mapPayloadPost(doc: PayloadPostDetail): PostDetail {
id: doc.id,
title: doc.title,
slug: doc.slug,
category:
typeof doc.category === "object" && doc.category ? doc.category.name : "",
categories: (doc.categories ?? [])
.map((c) => (typeof c === "object" && c ? c.name : null))
.filter((name): name is string => Boolean(name)),
readTime: doc.readTime,
excerpt: doc.excerpt,
thumbnail:
@@ -144,6 +149,10 @@ export async function getPostBySlug(slug: string, options?: { draft?: boolean })
depth: "2",
limit: "1",
});
// Draft/scheduled posts 404 for a normal visitor — draftMode's preview
// (options.draft, wired from the page's own draftMode() call) is the
// one legitimate way to view one before its scheduledPublishAt fires.
if (!options?.draft) params.set("where[status][equals]", "published");
const res = await fetch(`${PAYLOAD_URL}/api/posts?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
if (!res.ok) {
@@ -164,6 +173,13 @@ export async function getPostBySlug(slug: string, options?: { draft?: boolean })
// to numeric ids here would silently orphan every existing shopper's cart.
export type Product = {
id: string;
// The raw Payload numeric id — `id` above is the slug (used everywhere
// as the "commerce id" — cart, checkout, URLs), but a few relationships
// (Orders.items.product, WishlistItems.product) are real Payload
// relationship fields storing this number instead. Kept alongside the
// slug rather than replacing it, to avoid touching every existing
// slug-based call site.
numericId: number;
name: string;
description: string;
price: number;
@@ -184,6 +200,10 @@ export type Product = {
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: string | null;
// Per-product opt-in for a wishlist heart on the homepage spotlight —
// independent of (in addition to) the global wishlistEnabled toggle,
// which still gates the feature site-wide regardless of this flag.
spotlightShowWishlist: boolean;
// Plain booleans, not the raw stock/threshold numbers — the public API
// has no reason to leak exact stock counts, callers only ever need
// "can this be bought right now". `outOfStock` on the product itself
@@ -207,6 +227,13 @@ export type Product = {
// storefront; the actual rate used for order totals is resolved and
// snapshotted server-side at checkout (api/checkout/route.ts).
taxRatePercent: number | null;
// No shipping cost for this product at all (e.g. a digital download) —
// never shows "zzgl. Versand" on its own product page, and doesn't count
// toward "does this cart need a shipping line" (lib/cartTotals.ts's
// cartHasShippableItem()). A cart with even one item that does NOT have
// this set still gets charged/shown the normal shipping cost — this only
// exempts the individual product, not the whole cart.
noShippingCost: boolean;
variants: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
};
@@ -226,11 +253,13 @@ type PayloadProduct = {
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: { url: string } | number | null;
spotlightShowWishlist: boolean;
trackInventory: boolean;
stock: number | null;
allowBackorder: boolean;
lowStockThreshold: number | null;
taxRatePercent: number | null;
noShippingCost: boolean;
variants:
| {
name: string;
@@ -273,6 +302,7 @@ function maxPurchasableQty(trackInventory: boolean, stock: number | null, allowB
export function mapPayloadProduct(product: PayloadProduct): Product {
return {
id: product.slug,
numericId: product.id,
name: product.name,
description: product.description ?? "",
price: product.price,
@@ -287,10 +317,12 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
spotlightText: product.spotlightText || null,
spotlightImage:
typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : null,
spotlightShowWishlist: product.spotlightShowWishlist,
outOfStock: isOutOfStock(product.trackInventory, product.stock, product.allowBackorder),
lowStock: isLowStock(product.trackInventory, product.stock, product.lowStockThreshold),
maxQty: maxPurchasableQty(product.trackInventory, product.stock, product.allowBackorder),
taxRatePercent: product.taxRatePercent ?? null,
noShippingCost: product.noShippingCost,
variants: (product.variants ?? []).map((v) => ({
name: v.name,
priceOverride: v.priceOverride,
@@ -335,6 +367,23 @@ export async function getProductBySlug(slug: string): Promise<Product | null> {
// from getProducts()'s slug-keyed catalog (an order can reference a
// product that's since been deactivated/deleted, and slugs aren't even
// the key an order item stores).
// Powers /konto/merkliste — WishlistItems.product is a real numeric
// relationship (see Product.numericId's own comment), so displaying the
// wishlist needs a numeric-id lookup rather than getProducts()'s
// slug-keyed list.
export async function getProductsByIds(ids: number[]): Promise<Product[]> {
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length === 0) return [];
const params = new URLSearchParams({ "where[id][in]": uniqueIds.join(","), depth: "2", limit: String(uniqueIds.length) });
const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { next: { revalidate: 60 } });
if (!res.ok) {
console.error(`getProductsByIds: Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: PayloadProduct[] } = await res.json();
return (data.docs ?? []).map(mapPayloadProduct);
}
export async function getProductImagesByIds(ids: number[]): Promise<Map<number, string>> {
const uniqueIds = [...new Set(ids)];
const map = new Map<number, string>();
@@ -349,6 +398,21 @@ export async function getProductImagesByIds(ids: number[]): Promise<Map<number,
return map;
}
// Resolves a bare media id to its download URL — used by
// /konto/bestellungen/[orderNumber] for order.dhlReturnLabelMedia, which
// stays a raw id on the order fetch itself (that fetch is deliberately
// depth=0, see getCustomerOrderDetail's own comment) rather than bumping
// that fetch's depth just for this one occasional field. `no-store`, not
// ISR-cached like getProductImagesByIds — a return label is a one-off,
// account-specific document, not shared/reusable content worth caching.
export async function getMediaUrlById(id: number): Promise<{ url: string; filename: string } | null> {
const res = await fetch(`${PAYLOAD_URL}/api/media/${id}`, { cache: "no-store" });
if (!res.ok) return null;
const data: { url?: string; filename?: string } = await res.json();
if (!data.url) return null;
return { url: data.url, filename: data.filename ?? "download.pdf" };
}
// Derived from getProducts() (same 60s-ISR-cached fetch every other
// discovery surface already uses) instead of its own separate Payload
// query — also what lets the auto-spotlight rule below just be a plain
@@ -638,9 +702,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 +713,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 +881,11 @@ export type EmailTemplateType =
| "order-shipped"
| "order-cancelled"
| "order-return-requested"
| "order-returned";
| "order-returned"
| "order-tracking-added"
| "order-tracking-corrected"
| "order-delivered"
| "payment-method-switched";
type PayloadEmailTemplate = {
type: EmailTemplateType;
@@ -806,6 +893,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,
@@ -851,6 +943,12 @@ export type CompanySettings = {
sellerCity: string;
sellerCountry: string;
sellerEmail: string;
// Admin-editable override for outgoing mail's "Von"-Feld (company-settings
// "Adresse & Kontakt" tab, added 2026-07-29 alongside the SPF/SMTP-account
// switch to einfach-produktiv.com) — falls back to sellerName/sellerEmail
// when empty, see orderEmail.ts/alertAdmin.ts's own comments.
emailFromName: string | null;
emailFromAddress: string | null;
vatId: string;
taxRatePercent: number;
// Kleinunternehmerregelung (§19 UStG) — when true, checkout forces every
@@ -863,6 +961,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),
@@ -924,6 +1023,89 @@ export async function getKleinunternehmer(): Promise<boolean> {
return data.docs?.[0]?.kleinunternehmer ?? false;
}
// Same ISR-cached, public-catalog-freshness fetch as getKleinunternehmer()
// above — gates the whole Wishlist feature (heart icon, /konto/merkliste,
// the Navbar link) site-wide. Deliberately off by default (see
// CompanySettings.ts's own field comment) so the feature stays entirely
// invisible in the frontend until a tenant actually wants it, rather than
// shipping a half-finished-looking icon everywhere.
export async function getWishlistEnabled(): Promise<boolean> {
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getWishlistEnabled: Payload returned ${res.status} ${res.statusText}`);
return false;
}
const data: { docs?: { wishlistEnabled?: boolean }[] } = await res.json();
return data.docs?.[0]?.wishlistEnabled ?? false;
}
// Same pattern as getWishlistEnabled() — gates the Navbar's search icon
// (SearchOverlay.tsx). Off by default so the feature stays invisible
// until a tenant explicitly wants it.
export async function getSearchEnabled(): Promise<boolean> {
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getSearchEnabled: Payload returned ${res.status} ${res.statusText}`);
return false;
}
const data: { docs?: { searchEnabled?: boolean }[] } = await res.json();
return data.docs?.[0]?.searchEnabled ?? false;
}
// Same pattern as getWishlistEnabled()/getSearchEnabled() — gates the
// shop overview's price-range filter (ProductGrid.tsx/PriceRangeFilter.tsx).
export async function getShopFilterEnabled(): Promise<boolean> {
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getShopFilterEnabled: Payload returned ${res.status} ${res.statusText}`);
return false;
}
const data: { docs?: { shopFilterEnabled?: boolean }[] } = await res.json();
return data.docs?.[0]?.shopFilterEnabled ?? false;
}
// Same pattern — gates the blog overview's category filter (blog/page.tsx).
export async function getBlogFilterEnabled(): Promise<boolean> {
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getBlogFilterEnabled: Payload returned ${res.status} ${res.statusText}`);
return false;
}
const data: { docs?: { blogFilterEnabled?: boolean }[] } = await res.json();
return data.docs?.[0]?.blogFilterEnabled ?? false;
}
// Same pattern — gates /konto/bestellungen's status/paymentStatus/year filters.
export async function getOrderFilterEnabled(): Promise<boolean> {
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getOrderFilterEnabled: Payload returned ${res.status} ${res.statusText}`);
return false;
}
const data: { docs?: { orderFilterEnabled?: boolean }[] } = await res.json();
return data.docs?.[0]?.orderFilterEnabled ?? false;
}
export type SeoSettings = {
defaultTitle: string | null;
titleTemplate: string | null;
@@ -936,10 +1118,11 @@ export type SeoSettings = {
// filling in the CompanySettings SEO tab is optional, not a hard
// dependency for the site to render sensible metadata.
const SEO_SETTINGS_FALLBACK: SeoSettings = {
defaultTitle: "einfach produktiv. Werkzeuge und Impulse für einen leichteren Alltag",
defaultTitle: "einfach produktiv. Weil du auch noch ein Leben hast",
titleTemplate: "%s | einfach produktiv.",
defaultDescription: "Werkzeuge, Impulse und ein Blog für mehr Klarheit im Alltag.",
defaultOgImage: null,
defaultDescription:
"Kleine Impulse, praktische Werkzeuge und ehrliche Gedanken für mehr Klarheit im Alltag weil du auch noch ein Leben hast.",
defaultOgImage: "/og-image.png",
};
// Same ISR-cached, public-catalog-freshness fetch as getKleinunternehmer()
+19 -3
View File
@@ -1,4 +1,4 @@
import { sendOrderConfirmationEmail, type OrderConfirmationEmailData } from "../orderEmail";
import { sendOrderConfirmationEmail, sendPaymentSwitchedEmail, type OrderConfirmationEmailData } from "../orderEmail";
import { sendCriticalAlert } from "../alertAdmin";
// The `order` snapshot returned by the backend's confirm-payment endpoint
@@ -9,7 +9,13 @@ import { sendCriticalAlert } from "../alertAdmin";
// templates), so it returns everything needed here instead of the
// frontend needing an authenticated order-read path it doesn't otherwise
// have (ORDER_SERVICE_SECRET only ever authorizes *creating* an order).
export type ConfirmPaymentOrderSnapshot = OrderConfirmationEmailData & { customerEmail: string };
export type ConfirmPaymentOrderSnapshot = OrderConfirmationEmailData & {
customerEmail: string;
// Present only when this payment came from switchPaymentToStripe.ts (an
// existing Überweisung order moved to Stripe) — see this function's own
// branch below.
paymentSwitchedAt?: string;
};
// Called from both the real Stripe webhook route and its PAYMENT_TEST_MODE
// test-confirm sibling, right after confirm-payment reports success (and
@@ -17,10 +23,20 @@ export type ConfirmPaymentOrderSnapshot = OrderConfirmationEmailData & { custome
// this). Mirrors exactly what app/api/checkout/route.ts already does for
// a manual/Überweisung order today, just triggered from the payment
// webhook instead of the checkout request itself for gated methods.
//
// A payment-method switch (paymentSwitchedAt set) sends a short dedicated
// "Zahlung erhalten" confirmation instead — the customer already got the
// full order-confirmation email (with its invoice) when they originally
// placed the Überweisung order; resending that same email here would read
// as a second, brand-new purchase.
export async function sendConfirmedPaymentEmail(order: ConfirmPaymentOrderSnapshot): Promise<void> {
const { customerEmail, ...emailData } = order;
try {
await sendOrderConfirmationEmail(emailData, customerEmail);
if (order.paymentSwitchedAt) {
await sendPaymentSwitchedEmail(emailData, customerEmail);
} else {
await sendOrderConfirmationEmail(emailData, customerEmail);
}
} catch (err) {
sendCriticalAlert("Bestätigungs-Mail konnte nach Zahlungsbestätigung nicht gesendet werden", {
orderNumber: order.orderNumber,
+2
View File
@@ -20,10 +20,12 @@ export type RawProduct = {
id: number;
slug: string;
name: string;
sku: string | null;
price: number;
active: boolean;
image: { url: string } | number | null;
taxRatePercent: number | null;
noShippingCost: boolean;
bundleItems: { product: { id: number; name: string } | number; quantity: number }[] | null;
variants: RawProductVariant[] | null;
trackInventory: boolean;
+46
View File
@@ -0,0 +1,46 @@
// Thin client for the backend's DHL custom endpoints (src/lib/endpoints/
// dhlValidatePostNumber.ts, dhlAutocompleteAddress.ts) — own copy per repo,
// same "no shared package yet" convention as app/lib/tracking.ts.
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
export async function validateDhlPostNumber(args: {
postNumber: string;
firstName: string;
lastName: string;
}): Promise<{ ok: true; valid: boolean } | { ok: false; reason: string }> {
const params = new URLSearchParams({ tenantSlug: TENANT_SLUG, ...args });
try {
const res = await fetch(`${PAYLOAD_URL}/api/dhl/validate-post-number?${params}`, {
signal: AbortSignal.timeout(8000),
});
const data = await res.json();
if (!res.ok || !data.ok) return { ok: false, reason: data.reason ?? "Postnummer konnte nicht geprüft werden." };
return { ok: true, valid: Boolean(data.valid) };
} catch {
return { ok: false, reason: "Postnummer-Prüfung ist gerade nicht erreichbar." };
}
}
export type DhlAddressSuggestion = {
street: string;
houseNumber?: string;
zip: string;
city: string;
country: string;
};
export async function autocompleteDhlAddress(query: string): Promise<DhlAddressSuggestion[]> {
if (query.trim().length < 3) return [];
const params = new URLSearchParams({ tenantSlug: TENANT_SLUG, query });
try {
const res = await fetch(`${PAYLOAD_URL}/api/dhl/autocomplete-address?${params}`, {
signal: AbortSignal.timeout(5000),
});
const data = await res.json();
if (!res.ok || !data.ok) return [];
return data.suggestions as DhlAddressSuggestion[];
} catch {
return [];
}
}
+102
View File
@@ -0,0 +1,102 @@
import type { CompanySettings, Product } from "./payload";
// Pure JSON-LD builders — schema.org structured data for Google rich
// snippets (business info, product rich results, article cards). No
// component/rendering logic here; callers render the result via
// `<script type="application/ld+json">`. Kept separate from
// emailTemplates.ts/invoiceData.ts's own seller-formatting logic since
// schema.org's shape requirements are different from what an email/PDF
// needs (e.g. a `PostalAddress` object, not formatted address lines).
const SITE_URL = "https://einfach-produktiv.mk360.de";
// One Organization node reused as `publisher`/`seller` wherever those
// are needed (Article, Product) — schema.org allows (and Google prefers)
// linking back to a single canonical Organization via @id rather than
// repeating the full object on every page.
export function buildOrganizationSchema(seller: CompanySettings | null): Record<string, unknown> {
if (!seller) {
// Minimal fallback — still valid Organization markup even if
// company-settings is unreachable, better than emitting nothing at
// all (a page load shouldn't fail over structured data).
return {
"@context": "https://schema.org",
"@type": "Organization",
"@id": `${SITE_URL}/#organization`,
name: "einfach produktiv",
url: SITE_URL,
};
}
return {
"@context": "https://schema.org",
"@type": "Organization",
"@id": `${SITE_URL}/#organization`,
name: seller.sellerName,
url: SITE_URL,
email: seller.sellerEmail,
address: {
"@type": "PostalAddress",
streetAddress: seller.sellerStreet,
postalCode: seller.sellerZip,
addressLocality: seller.sellerCity,
addressCountry: seller.sellerCountry === "Deutschland" ? "DE" : seller.sellerCountry,
},
// vatID is a real schema.org Organization property (distinct from
// taxID) — only included when set, same "omit rather than print an
// empty value" convention as buildLegalFooterLines() elsewhere.
...(seller.vatId ? { vatID: seller.vatId } : {}),
};
}
export function buildProductSchema(product: Product, url: string, seller: CompanySettings | null): Record<string, unknown> {
return {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
description: product.description,
image: product.image,
url,
// No reviews/ratings system exists yet — `aggregateRating` is
// optional in the spec and deliberately omitted rather than faked;
// add it here once real reviews exist, not before.
offers: {
"@type": "Offer",
url,
priceCurrency: "EUR",
price: product.price.toFixed(2),
availability: product.outOfStock
? "https://schema.org/OutOfStock"
: "https://schema.org/InStock",
seller: { "@id": `${SITE_URL}/#organization` },
},
...(seller ? { brand: { "@type": "Brand", name: seller.sellerName } } : {}),
};
}
export function buildArticleSchema(
post: { title: string; excerpt: string; thumbnail: string | null; publishedAt: string; slug: string },
seller: CompanySettings | null,
): Record<string, unknown> {
const url = `${SITE_URL}/blog/${post.slug}`;
return {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description: post.excerpt,
url,
mainEntityOfPage: url,
datePublished: post.publishedAt,
...(post.thumbnail ? { image: post.thumbnail } : {}),
// Single-author blog with no author field on Posts (see Posts.ts) —
// "Björn" is already hardcoded in the page's own author-bio block,
// matched here rather than left out entirely.
author: { "@type": "Person", name: "Björn" },
publisher: seller ? { "@id": `${SITE_URL}/#organization` } : { "@type": "Organization", name: "einfach produktiv" },
};
}
// Renders as a plain object, not a component — callers do
// `<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />`
// directly (no need for a shared component around one line of JSX, and
// keeps this file free of "use client"/React concerns so server
// components can import it without issue).
+35 -1
View File
@@ -9,6 +9,16 @@ import type { NewsletterOptInSource } from "./brevo";
// hero form, /challenge's EmailCapture) — four places with the same
// email+consent+submit shape but different markup/visual style, so only
// the logic is shared here rather than a one-size-fits-all component.
// Per the user's own explicit wording request, see the newsletter-DOI
// memory — kept here once rather than duplicated across all 4 forms.
const SUCCESS_MESSAGE = "Fast geschafft! Schau kurz in dein Postfach da wartet schon eine Mail von uns.";
// Deliberately routed through the *error* state, not a success variant —
// per explicit feedback: swapping the whole form out for a bare message
// (the real-success treatment) felt wrong for "you're already signed up,
// nothing to do" — the form should stay visible, with a small note below
// it, exactly like every other inline validation error already does.
const ALREADY_SUBSCRIBED_MESSAGE = "Diese E-Mail-Adresse ist schon für unseren Newsletter angemeldet.";
export function useNewsletterSignup(source: NewsletterOptInSource) {
const [email, setEmail] = useState("");
const [emailError, setEmailError] = useState("");
@@ -17,15 +27,34 @@ export function useNewsletterSignup(source: NewsletterOptInSource) {
const [error, setError] = useState("");
const emailRef = useRef<HTMLInputElement>(null);
// Clears a previous submit-time error (real failure or "already
// subscribed") the moment the customer interacts with the form again —
// same "stale validation message shouldn't linger" behavior
// emailError already had for itself, extended to the submit-result
// error too, since it's otherwise easy to misread as still describing
// the current (possibly already-corrected) input.
function clearSubmitError() {
if (status === "error") {
setStatus("idle");
setError("");
}
}
function handleEmailChange(value: string) {
setEmail(value);
if (emailError) setEmailError("");
clearSubmitError();
}
function handleEmailBlur(value: string) {
setEmailError(validateEmailFormat(value));
}
function handleConsentChange(checked: boolean) {
setConsent(checked);
clearSubmitError();
}
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const formatError = validateEmailFormat(email);
@@ -48,6 +77,11 @@ export function useNewsletterSignup(source: NewsletterOptInSource) {
setStatus("error");
return;
}
if (data.alreadySubscribed) {
setError(ALREADY_SUBSCRIBED_MESSAGE);
setStatus("error");
return;
}
setStatus("success");
} catch {
setError("Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut.");
@@ -55,5 +89,5 @@ export function useNewsletterSignup(source: NewsletterOptInSource) {
}
}
return { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit };
return { email, emailError, consent, handleConsentChange, status, error, successMessage: SUCCESS_MESSAGE, emailRef, handleEmailChange, handleEmailBlur, handleSubmit };
}
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { useCallback, useEffect, useState } from "react";
// Server-backed (needs a logged-in customer, unlike the cart which works
// for guests via localStorage — a wishlist tied to nothing would just
// evaporate on the next visit, which defeats the point) — so this can't
// reuse cart.ts's useSyncExternalStore-over-localStorage pattern. Instead:
// a plain fetch on mount + a custom window event so every WishlistButton/
// the Navbar badge on the page stays in sync after any one of them toggles
// an item, without a shared cache library.
const WISHLIST_EVENT = "ep-wishlist-updated";
type WishlistItem = { id: number; productId: number; variant: string };
let cachedItems: WishlistItem[] | null = null;
async function fetchWishlist(): Promise<WishlistItem[]> {
const res = await fetch("/api/account/wishlist", { cache: "no-store" });
if (!res.ok) return [];
const data: { items?: WishlistItem[] } = await res.json();
return data.items ?? [];
}
function broadcast() {
window.dispatchEvent(new Event(WISHLIST_EVENT));
}
export function useWishlist() {
const [items, setItems] = useState<WishlistItem[]>(cachedItems ?? []);
const [loading, setLoading] = useState(cachedItems === null);
const load = useCallback(async () => {
const fresh = await fetchWishlist();
cachedItems = fresh;
setItems(fresh);
setLoading(false);
}, []);
useEffect(() => {
load();
window.addEventListener(WISHLIST_EVENT, load);
return () => window.removeEventListener(WISHLIST_EVENT, load);
}, [load]);
const isWishlisted = useCallback(
(productId: number, variant = "") => items.some((i) => i.productId === productId && i.variant === variant),
[items],
);
// Optimistic: flips the LOCAL list immediately (this component's own
// `items`/`cachedItems`), reconciles with the server response (or
// reverts on failure) rather than waiting for the round trip — same
// "feels instant" reasoning as AddToCartButton. Crucially, `broadcast()`
// (which tells every OTHER useWishlist() instance — e.g. the Navbar
// badge — to refetch) only fires AFTER the request settles, never
// before: broadcasting immediately after the optimistic update used to
// race the POST itself — another instance's resulting `load()` could
// hit the server before the toggle had actually been persisted there,
// fetch the pre-toggle list, and then never get told to refetch again,
// leaving e.g. the Navbar count permanently one behind the real value.
const toggle = useCallback(async (productId: number, variant = "") => {
const wasWishlisted = cachedItems?.some((i) => i.productId === productId && i.variant === variant) ?? false;
const optimistic = wasWishlisted
? (cachedItems ?? []).filter((i) => !(i.productId === productId && i.variant === variant))
: [...(cachedItems ?? []), { id: -1, productId, variant }];
cachedItems = optimistic;
setItems(optimistic);
try {
const res = await fetch("/api/account/wishlist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productId, variant }),
});
if (res.status === 401) {
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant }] : optimistic.filter((i) => i.productId !== productId);
setItems(cachedItems);
return { ok: false as const, unauthorized: true as const };
}
if (!res.ok) throw new Error("request failed");
const data: { ok: boolean; wishlisted?: boolean } = await res.json();
if (!data.ok) throw new Error("toggle failed");
// Reconcile with the server's own id (needed for a later toggle-off
// that hasn't refetched the list yet) rather than trusting the
// optimistic placeholder id (-1) forever.
await load();
broadcast();
return { ok: true as const, wishlisted: data.wishlisted ?? !wasWishlisted };
} catch {
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant }] : optimistic.filter((i) => i.productId !== productId);
setItems(cachedItems);
return { ok: false as const };
}
}, []);
return { items, loading, isWishlisted, toggle, count: items.length };
}
+29 -10
View File
@@ -40,17 +40,36 @@ export default function NewsletterConfirmedPage() {
>
Bestätigt!
</p>
<p
className="font-semibold text-h3 text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Du bist jetzt Teil unseres Newsletters.
</p>
<div className="h-[0.125rem] w-8 bg-brand" />
<p className="text-body text-text-muted max-w-[28rem] pt-2">
Schön, dass du dabei bist! Ab jetzt bekommst du hin und wieder Impulse, neue Produkte
und kleine Erinnerungen von uns, damit dein Alltag ein bisschen leichter wird.
</p>
<div className="flex flex-col gap-4 text-left text-body text-text-muted max-w-[28rem] pt-2">
<p>Hallo,</p>
<p>schön, dass du da bist!</p>
<p>
Ab jetzt bekommst du wöchentlich meine Sonntags-Impulse direkt in dein Postfach
mit kurzen Gedanken und praktischen Impulsen für mehr Klarheit im Alltag.
</p>
<p>Als kleines Dankeschön habe ich direkt etwas für dich:</p>
<div className="flex flex-col gap-3 bg-bg-muted rounded-md px-6 py-5">
<p className="font-semibold text-text-primary">🧭 Dein Klarheitskompass</p>
<p>Du erhältst ihn in einer separaten E-Mail in den nächsten Minuten er hilft dir, kurz innezuhalten und dich zu fragen:</p>
<ul className="flex flex-col gap-1 list-disc pl-5">
<li>Was beschäftigt mich gerade?</li>
<li>Was ist wirklich wichtig?</li>
<li>Worauf möchte ich meinen Fokus legen?</li>
</ul>
</div>
<p>Bis die erste Mail am Sonntag kommt, habe ich noch eine kleine Einladung:</p>
<p>
Beobachte in den nächsten Tagen einfach einmal, womit du deine Aufmerksamkeit
verbringst. Nicht bewerten. Nur wahrnehmen.
</p>
<p>Bis Sonntag</p>
<p className="text-text-primary font-semibold">Björn</p>
</div>
<Link
href="/shop"
className="inline-flex items-center justify-center py-4 px-8 mt-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
+9 -9
View File
@@ -39,7 +39,7 @@ const steps = [
export function HowItWorks() {
return (
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-12 sm:py-16 px-[var(--layout-padding-x)]">
<Reveal className="flex flex-col gap-2 items-center text-center">
<p
className="font-semibold text-h-emphasis text-text-primary"
@@ -53,14 +53,14 @@ export function HowItWorks() {
</Reveal>
{/* Same pattern as /todo-cards's HowItWorks: arrows always visible
(rotated to point down while stacked below md:), RevealGroup/
(rotated to point down while stacked below sm:), RevealGroup/
RevealItem stagger the steps in. lg:px-[10rem] mirrors Figma's
Desktop-only px-[160px] inset (no fluid token — pure aesthetic
narrowing, fine to just drop below lg:). */}
<RevealGroup className="flex flex-col md:flex-row gap-8 items-center md:items-start w-full lg:px-[10rem]">
<RevealGroup className="flex flex-col sm:flex-row gap-8 items-center sm:items-start w-full lg:px-[10rem]">
{steps.map((step, i) => (
<Fragment key={step.title}>
<RevealItem className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs md:max-w-none">
<RevealItem className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none">
<Image
src={step.icon}
alt=""
@@ -72,15 +72,15 @@ export function HowItWorks() {
<p className="text-body-sm text-text-primary text-center">{step.desc}</p>
</RevealItem>
{i < steps.length - 1 && (
// md:mt-[1.5rem] centers the arrow on the h-16 icon above it,
// sm:mt-[1.5rem] centers the arrow on the h-16 icon above it,
// same technique as todo-cards'/Challenge's own step
// connector. Below md: pulled up with a negative margin so it
// connector. Below sm: pulled up with a negative margin so it
// sits nearer the icon row above it instead of dead-center in
// the whole gap between steps (fixed 2026-07-24, consistency
// with Challenge's icon-at-top layout). Bigger below md:
// with Challenge's icon-at-top layout). Bigger below sm:
// (w-8 h-8, was w-6 h-6) per explicit feedback.
<div className="flex items-center justify-center shrink-0 -mt-2 md:mt-[1.5rem]">
<StepArrow className="w-8 h-8 rotate-90 md:w-10 md:h-4 md:rotate-0" />
<div className="flex items-center justify-center shrink-0 -mt-2 sm:mt-[1.5rem]">
<StepArrow className="w-8 h-8 rotate-90 sm:w-10 sm:h-4 sm:rotate-0" />
</div>
)}
</Fragment>
@@ -35,16 +35,17 @@ 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 (
<section className="bg-bg-base w-full overflow-hidden">
{/* Same lg:-only structural exception as Home/todo-cards Hero (see
figma-to-nextjs skill Gotcha #5) — a wide fixed-ratio photo next
to a text column gets too cramped at Tablet widths under the
site-wide md: convention. lg:items-center removed — see the
breadcrumb/centering comment below, same fix as /todo-cards. */}
to a text column gets too cramped at Tablet widths even under
the site-wide sm: (640px) structural consolidation. lg:items-
center removed — see the breadcrumb/centering comment below,
same fix as /todo-cards. */}
{/* pt-10 md:pt-12, no lg override — same fix and same reasoning as
/todo-cards's Hero: this is now the only thing positioning the
breadcrumb, and it must match /todo-cards's and /challenge's
@@ -111,9 +112,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 +156,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">
+6 -6
View File
@@ -140,11 +140,11 @@ export default function NotFound() {
<div className="h-[0.125rem] w-8 bg-brand" />
</Reveal>
<RevealGroup className="grid grid-cols-1 md:grid-cols-3 gap-10 md:gap-8 w-full max-w-[64rem] divide-y md:divide-y-0 md:divide-x divide-border">
<RevealGroup className="grid grid-cols-1 sm:grid-cols-3 gap-10 sm:gap-8 w-full max-w-[64rem] divide-y sm:divide-y-0 sm:divide-x divide-border">
{helpLinks.map((link) => (
<RevealItem
key={link.href}
className="group flex flex-col items-center text-center gap-3 pt-10 md:pt-0 first:pt-0 px-4 transition-transform duration-200 hover:-translate-y-1"
className="group flex flex-col items-center text-center gap-3 pt-10 sm:pt-0 first:pt-0 px-4 transition-transform duration-200 hover:-translate-y-1"
>
<div className="flex size-14 items-center justify-center rounded-full bg-bg-muted text-brand transition-transform duration-300 group-hover:scale-110">
{link.icon}
@@ -180,17 +180,17 @@ export default function NotFound() {
reusing an unrelated existing asset — same real-photo
extraction convention the Figma rebuild used, just done on
the flat mockup image instead of in Figma. */}
<Reveal className="relative w-full flex items-stretch h-[14rem] md:h-[16rem] bg-bg-muted overflow-hidden">
<div className="relative w-full md:w-[45%] shrink-0">
<Reveal className="relative w-full flex items-stretch h-[14rem] sm:h-[16rem] bg-bg-muted overflow-hidden">
<div className="relative w-full sm:w-[45%] shrink-0">
<Image
alt=""
src="/404-testimonial-photo.jpg"
width={810}
height={291}
sizes="(min-width: 768px) 45vw, 100vw"
sizes="(min-width: 640px) 45vw, 100vw"
className="absolute -inset-1 w-[calc(100%+0.5rem)] h-[calc(100%+0.5rem)] object-cover"
/>
<div className="hidden md:block absolute inset-y-0 right-0 w-40 bg-gradient-to-l from-bg-muted to-transparent" />
<div className="hidden sm:block absolute inset-y-0 right-0 w-40 bg-gradient-to-l from-bg-muted to-transparent" />
</div>
<div className="flex-1 flex flex-col justify-center gap-3 px-8 md:px-16 py-6">
<p
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
// Real min/max range (not preset toggle buckets, tried first and
// reverted 2026-07-30 — "keine toggle badges") — two plain number inputs,
// submitted via a small Client Component's router.push. Still a plain
// URL search param underneath (?minPrice=&maxPrice=), so the result stays
// shareable/bookmarkable like every other filter on the site.
export function PriceRangeFilter({
catalogMin,
catalogMax,
layout = "bar",
}: {
catalogMin: number;
catalogMax: number;
/** "bar" (default): horizontal row, wraps — used above the grid at
* <lg (see ProductGrid.tsx's mobile/tablet filter bar). "sidebar":
* stacked vertically to fit the lg:+ left sidebar column instead. */
layout?: "bar" | "sidebar";
}) {
const router = useRouter();
const searchParams = useSearchParams();
const [minPrice, setMinPrice] = useState(searchParams.get("minPrice") ?? "");
const [maxPrice, setMaxPrice] = useState(searchParams.get("maxPrice") ?? "");
const hasFilter = Boolean(searchParams.get("minPrice") || searchParams.get("maxPrice"));
function apply(e: React.FormEvent) {
e.preventDefault();
const params = new URLSearchParams();
if (minPrice) params.set("minPrice", minPrice);
if (maxPrice) params.set("maxPrice", maxPrice);
const qs = params.toString();
router.push(qs ? `/shop?${qs}` : "/shop");
}
function reset() {
setMinPrice("");
setMaxPrice("");
router.push("/shop");
}
const sidebar = layout === "sidebar";
return (
<form
onSubmit={apply}
className={sidebar ? "flex flex-col gap-3 items-stretch w-full" : "flex flex-wrap items-end gap-3 w-full pb-6"}
>
{sidebar && <p className="font-bold text-body-sm text-text-primary">Preis</p>}
<div className={sidebar ? "flex items-end gap-3 w-full" : "flex items-end gap-3"}>
<label className={sidebar ? "flex flex-col gap-1 flex-1 min-w-0" : "flex flex-col gap-1"}>
<span className="text-label text-text-muted">Von</span>
<input
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder={`${catalogMin}`}
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
className={`${sidebar ? "w-full" : "w-24"} border border-border rounded-sm px-3 py-2 text-body-sm text-text-primary bg-bg-base outline-none focus:border-brand transition-colors`}
/>
</label>
<label className={sidebar ? "flex flex-col gap-1 flex-1 min-w-0" : "flex flex-col gap-1"}>
<span className="text-label text-text-muted">Bis</span>
<input
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder={`${catalogMax}`}
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
className={`${sidebar ? "w-full" : "w-24"} border border-border rounded-sm px-3 py-2 text-body-sm text-text-primary bg-bg-base outline-none focus:border-brand transition-colors`}
/>
</label>
{!sidebar && <span className="text-body-sm text-text-muted"></span>}
</div>
<div className={sidebar ? "flex flex-col gap-2 items-stretch w-full" : "flex items-center gap-3"}>
<button
type="submit"
className={`${sidebar ? "w-full" : ""} px-4 py-2 rounded-sm bg-brand text-body-sm font-bold text-text-primary hover:brightness-95 active:scale-[0.97] transition-all`}
>
Anwenden
</button>
{hasFilter && (
<button
type="button"
onClick={reset}
className={`${sidebar ? "text-center" : ""} text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors`}
>
Zurücksetzen
</button>
)}
</div>
</form>
);
}
+65 -11
View File
@@ -1,10 +1,12 @@
import Link from "next/link";
import Image from "next/image";
import { getProducts, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { getProducts, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled, getShopFilterEnabled } from "../../lib/payload";
import { effectiveTaxRate } from "../../lib/cartTotals";
import { formatPrice, discountPercent } from "../../lib/format";
import { RevealGroup, RevealItem } from "../../components/Reveal";
import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
import { WishlistButton } from "../../components/WishlistButton";
import { PriceRangeFilter } from "./PriceRangeFilter";
// Server Component — fetches straight from Payload (getProducts(), ISR
// cached 60s) rather than going through the client-side useProducts()
@@ -12,16 +14,24 @@ import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
// there's no reason to pay for a client fetch when a server one already
// gives faster first paint and no loading flash.
export async function ProductGrid() {
const [allProducts, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
// Products have no category taxonomy today (only Posts do) — a "N
// checkboxes" category sidebar isn't buildable against real data yet, so
// this only covers price, the one dimension that already exists on every
// product. A real min/max range (PriceRangeFilter.tsx), not preset toggle
// buckets — tried buckets-as-toggle-chips first, reverted 2026-07-30
// ("keine toggle badges").
export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?: string; maxPrice?: string } }) {
const [allProducts, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled, shopFilterEnabled] = await Promise.all([
getProducts(),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
getWishlistEnabled(),
getShopFilterEnabled(),
]);
const products = allProducts.filter((p) => p.active);
const allActiveProducts = allProducts.filter((p) => p.active);
if (products.length === 0) {
if (allActiveProducts.length === 0) {
return (
<section className="w-full bg-bg-base flex flex-col items-center pb-16 md:pb-20 px-[var(--layout-padding-x)]">
<p className="text-body text-text-muted">Aktuell keine Produkte verfügbar.</p>
@@ -29,10 +39,49 @@ export async function ProductGrid() {
);
}
const catalogPrices = allActiveProducts.map((p) => p.price);
const catalogMin = Math.min(...catalogPrices);
const catalogMax = Math.max(...catalogPrices);
const minPrice = shopFilterEnabled && searchParams?.minPrice ? Number(searchParams.minPrice) : null;
const maxPrice = shopFilterEnabled && searchParams?.maxPrice ? Number(searchParams.maxPrice) : null;
const products = allActiveProducts.filter((p) => (minPrice === null || p.price >= minPrice) && (maxPrice === null || p.price <= maxPrice));
const hasSidebarFilter = shopFilterEnabled && catalogMin !== catalogMax;
return (
<section className="w-full bg-bg-base flex flex-col pb-16 md:pb-20 px-[var(--layout-padding-x)]">
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
{products.map((product) => {
{/* <lg: filter (if any) sits as its own bar above the grid — same as
before. lg+: it moves into a left sidebar instead (see aside
below), so it's hidden here to avoid rendering twice. */}
{hasSidebarFilter && (
<div className="lg:hidden">
<PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} />
</div>
)}
{/* lg:flex — a real left sidebar only once there's an actual filter
to put in it (hasSidebarFilter); with no filter, the grid alone
fills the row exactly as before, no empty reserved column. */}
<div className={hasSidebarFilter ? "lg:flex lg:gap-10 w-full" : "w-full"}>
{hasSidebarFilter && (
<aside className="hidden lg:block lg:w-56 shrink-0">
<PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} layout="sidebar" />
</aside>
)}
<div className="flex-1 min-w-0 flex flex-col">
{products.length === 0 && <p className="text-body text-text-muted pb-6">Keine Produkte in dieser Preisspanne gefunden.</p>}
{/* 2-up from the mobile breakpoint (sm, 640px) through 1023px — was
sm:grid-cols-12 with each card sm:col-span-3 (4-up), too narrow a
card through that tablet range. lg+ is now 3-up (col-span-4 of
12) rather than 4-up — narrowed to leave room for the sidebar
filter alongside it (see hasSidebarFilter above); with no
filter active the grid still renders at this same 3-up density,
simplest to keep one fixed lg: density rather than branching
the whole grid on hasSidebarFilter too. */}
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full">
{products.map((product) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
// A varianted product only reads as "ausverkauft" overall once
@@ -48,14 +97,14 @@ export async function ProductGrid() {
return (
<RevealItem
key={product.id}
className="group md:col-span-3 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full transition-transform duration-300 hover:-translate-y-1"
className="group lg:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full transition-transform duration-300 hover:-translate-y-1"
>
<div className="relative w-full aspect-[276/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 768px) 25vw, 100vw"
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
{fullyOutOfStock ? (
@@ -69,6 +118,9 @@ export async function ProductGrid() {
</span>
)
)}
{wishlistEnabled && (
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover />
)}
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
<p
@@ -119,8 +171,10 @@ export async function ProductGrid() {
</div>
</RevealItem>
);
})}
</RevealGroup>
})}
</RevealGroup>
</div>
</div>
</section>
);
}
+7 -2
View File
@@ -19,12 +19,17 @@ export const metadata: Metadata = {
},
};
export default function ShopPage() {
export default async function ShopPage({
searchParams,
}: {
searchParams: Promise<{ minPrice?: string; maxPrice?: string }>;
}) {
const resolvedSearchParams = await searchParams;
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<ShopHeader />
<ProductGrid />
<ProductGrid searchParams={resolvedSearchParams} />
<TrustRow />
</main>
<Footer />
+8 -8
View File
@@ -32,7 +32,7 @@ const steps = [
export function HowItWorks() {
return (
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-12 sm:py-16 px-[var(--layout-padding-x)]">
<Reveal className="flex flex-col gap-2 items-center text-center">
<p
className="font-semibold text-h-emphasis text-text-primary"
@@ -47,17 +47,17 @@ export function HowItWorks() {
{/* Arrows always visible (see Divider lesson — hiding them below a
breakpoint isn't the fix), just rotated 90° to point down while
stacked below md:. lg:px-[16.25rem] mirrors Figma's Desktop-only
stacked below sm:. lg:px-[16.25rem] mirrors Figma's Desktop-only
px-[260px] inset — that value doesn't get its own fluid token
since it's a pure aesthetic narrowing, not something that breaks
if it's just absent below lg:. RevealGroup/RevealItem stagger the
3 steps in on scroll, same pattern as Tools.tsx's card grid —
arrows aren't part of the stagger (purely decorative connectors,
not content), they just sit in the DOM between items. */}
<RevealGroup className="flex flex-col md:flex-row gap-8 items-center md:items-start w-full lg:px-[16.25rem]">
<RevealGroup className="flex flex-col sm:flex-row gap-8 items-center sm:items-start w-full lg:px-[16.25rem]">
{steps.map((step, i) => (
<Fragment key={step.title}>
<RevealItem className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs md:max-w-none">
<RevealItem className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none">
{/* Explicit per-icon width/height (real pixel dimensions, not
a guessed/uniform size) — matters since one of the three
source PNGs isn't square (icon-step-2 is 180x168); passing
@@ -79,13 +79,13 @@ export function HowItWorks() {
<p className="text-body-sm text-text-primary text-center">{step.desc}</p>
</RevealItem>
{i < steps.length - 1 && (
// md:mt-[1.625rem] (26px) centers the arrow on the h-16
// sm:mt-[1.625rem] (26px) centers the arrow on the h-16
// (64px) icon above it — (64 - arrow's own 16px height) / 2
// — same margin-based centering technique as Challenge's
// step connector, not just the same icon asset. Bigger below
// md: (w-8 h-8, was w-6 h-6) per explicit feedback.
<div className="flex items-center justify-center shrink-0 md:mt-[1.5rem]">
<StepArrow className="w-8 h-8 rotate-90 md:w-10 md:h-4 md:rotate-0" />
// sm: (w-8 h-8, was w-6 h-6) per explicit feedback.
<div className="flex items-center justify-center shrink-0 sm:mt-[1.5rem]">
<StepArrow className="w-8 h-8 rotate-90 sm:w-10 sm:h-4 sm:rotate-0" />
</div>
)}
</Fragment>
+10 -2
View File
@@ -97,10 +97,18 @@ export async function Pricing() {
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
</div>
<p className="text-label text-text-muted">{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}</p>
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
{kleinunternehmer
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
{!product.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
</div>
{/* Single product, no grid siblings to stay equal-height with —
plain conditional line, same reasoning as ProductSpotlight.tsx. */}
+14 -5
View File
@@ -50,7 +50,8 @@ export async function TodoKartenHero() {
{/* Same lg:-only structural exception as the Home Hero (see
figma-to-nextjs skill Gotcha #5) — a wide fixed-ratio photo next
to a text column is exactly the shape that gets too cramped at
Tablet widths under the site-wide md: convention. lg:items-center
Tablet widths even under the site-wide sm: (640px) structural
consolidation. lg:items-center
removed (default grid align-items is stretch) — that's what lets
the breadcrumb below sit at a fixed Y position across every hero
section on the site instead of shifting per page depending on how
@@ -123,12 +124,20 @@ export async function TodoKartenHero() {
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}</p>
<p className="text-label text-text-muted">
{kleinunternehmer
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
</div>
)}
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
{!product?.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
{/* Single product, no grid siblings to stay equal-height with —
plain conditional line, same reasoning as ProductSpotlight.tsx/Pricing.tsx. */}
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
+11 -2
View File
@@ -7,7 +7,8 @@ import { Pricing } from "./components/Pricing";
import { Footer } from "../components/Footer";
import { TestimonialsGrid } from "../components/TestimonialsGrid";
import { LiveTestimonialsGrid } from "../components/LiveTestimonialsGrid";
import { getTestimonials } from "../lib/payload";
import { getTestimonials, getProductBySlug, getCompanySettings } from "../lib/payload";
import { buildProductSchema } from "../lib/structuredData";
const title = "ToDo-Karten Kleine Karten. Große Wirkung.";
const description =
@@ -34,10 +35,18 @@ export const metadata: Metadata = {
export default async function TodoCardsPage() {
const { isEnabled: isPreview } = await draftMode();
const testimonials = await getTestimonials("todo-cards", { draft: isPreview });
const [testimonials, product, seller] = await Promise.all([
getTestimonials("todo-cards", { draft: isPreview }),
getProductBySlug("todo-karten"),
getCompanySettings(),
]);
const productSchema = product ? buildProductSchema(product, "https://einfach-produktiv.mk360.de/todo-cards", seller) : null;
return (
<>
{productSchema && (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
)}
<main className="flex flex-col flex-1">
<TodoKartenHero />
<HowItWorks />
@@ -77,6 +77,10 @@ export function VersandSections({
wir kostenlos.
</p>
<p>Alle angegebenen Preise verstehen sich inklusive der gesetzlichen Mehrwertsteuer.</p>
<p>
Einzelne Produkte können von den oben genannten Versandkosten ausgenommen sein (z.B.
digitale Produkte) das ist dann direkt auf der jeweiligen Produktseite vermerkt.
</p>
</Section>
<Section id="liefergebiet" title="Liefergebiet" withAnchor={withAnchors}>
+16 -13
View File
@@ -75,14 +75,18 @@ export default async function WiderrufPage() {
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
)}
{page?.attachment && (
<a
href={page.attachment.url}
target="_blank"
rel="noopener noreferrer"
download
className="group flex items-center gap-5 bg-bg-muted hover:bg-bg-white border border-border rounded-md p-6 transition-colors"
>
{/* Generated live from company-settings (/api/muster-widerrufsformular)
instead of the CMS attachment field see that route's own
comment on why: the static uploaded PDF's "An:" address
silently went stale whenever an admin updated the
Impressum's Anbieterdaten without also re-exporting this
file by hand. */}
<a
href="/api/muster-widerrufsformular"
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-5 bg-bg-muted hover:bg-bg-white border border-border rounded-md p-6 transition-colors"
>
<div className="flex size-12 shrink-0 items-center justify-center rounded-sm bg-bg-white border border-border text-brand">
<svg viewBox="0 0 24 24" className="size-6" fill="none" aria-hidden="true">
<path
@@ -103,20 +107,19 @@ export default async function WiderrufPage() {
</div>
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<p className="font-semibold text-body text-text-primary">
{page.attachment.title || "Muster-Widerrufsformular (PDF)"}
Muster-Widerrufsformular (PDF)
</p>
<p className="text-body-sm text-text-muted">Herunterladen</p>
<p className="text-body-sm text-text-muted">In neuem Tab öffnen</p>
</div>
<svg
viewBox="0 0 24 24"
className="size-5 shrink-0 text-text-muted group-hover:text-brand group-hover:translate-y-0.5 transition-all"
className="size-5 shrink-0 text-text-muted group-hover:text-brand group-hover:translate-x-0.5 transition-all"
fill="none"
aria-hidden="true"
>
<path d="M12 4v13m0 0-5-5m5 5 5-5M5 21h14" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 12h14m0 0-6-6m6 6-6 6" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</a>
)}
</div>
</div>
+2 -2
View File
@@ -455,8 +455,8 @@
}
},
"node_modules/@einfach-produktiv/invoicing": {
"version": "0.2.2",
"resolved": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#ceaa437724dae1adfd582ca209300d2ee0a10e68",
"version": "0.2.9",
"resolved": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#87cf1db330e6a415fbcd31b83c6c4470a73b07a9",
"dependencies": {
"@e-invoice-eu/core": "^3.1.1"
},
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 675 KiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB