Compare commits

...

185 Commits

Author SHA1 Message Date
Marco 988f259371 Add internal redirect short links (/r/<code>)
A static short link — e.g. printed on a QR code — that resolves to a
Payload-editable internal target path, so the link itself never needs
reprinting when the underlying content moves. Tracks a click count.
2026-08-25 12:17:08 +00:00
Marco a085a75dea Allow blog meta row (category/readtime/date) to wrap on narrow cards
Long category names no longer force horizontal overflow — the row
wraps to a second line instead, with each segment kept from breaking
mid-word via whitespace-nowrap.
2026-08-24 22:53:12 +00:00
Marco b6e76ccd22 Add bespoke product detail pages for Der Alltagsstift and the mug
/der-alltagsstift mirrors the todo-cards pattern (Hero/HowItWorks/
Focus/Pricing) — renamed from "Der Eine" for brand-tonality reasons,
copy grounded in visually-verified product facts only.

/tasse-die-pause is a deliberately minimal Hero+Pricing scaffold —
naming/design concept for that product is still undecided, so the
headline reads product.name live from Payload instead of a hardcoded
tagline.
2026-08-24 22:52:41 +00:00
Marco c065a3223f Link product images to detail pages, not just titles
Image sits as a sibling to the badge/WishlistButton overlays, not
wrapping them — nested interactive elements would be invalid HTML and
would fire navigation on a wishlist click.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 08:19:42 +00:00
Marco 7f95711048 Link cart line-item titles/images to product detail pages
Cart line items had their own separate markup from ProductCard.tsx and
never got the title-link treatment — same product.href check, only
rendered as a link when the product actually has a detail page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 08:13:01 +00:00
Marco e7ad9cd88d Document ProductCard consolidation, swipe gallery, dynamic legal-page sourcing, shipping-methods, NotifyMeForm redesign
README had fallen behind several recent changes: the shared ProductCard
component (was still describing three independently-duplicated grids),
ProductGallery's touch-swipe support, legal-pages' contentPart2 field
and company-settings-sourced name/address/email, shipping-methods as
the real cost/threshold source (lib/shipping.ts's old hardcoded
constants were deleted), and NotifyMeForm's single-row redesign.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 08:07:51 +00:00
Marco 340dcd2c94 Fix notify-form input height to actually match the cart button
py-3 alone didn't match AddToCartInlineButton's real height — that
button's tallest child is its 1.875rem cart-icon image, not its text,
so an input with the same padding but only text content still rendered
shorter. Explicit h-14 matches the button's actual 56px.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 07:23:34 +00:00
Marco 70bc839f48 Restore reserved low-stock line, clearer notify-me placeholder text
NotifyMeForm's single-row redesign matched AddToCartInlineButton's
button height exactly, which made the sold-out card's earlier
low-stock-line omission overcorrect — it ended up shorter than its
siblings instead of taller. Keeping that line unconditional (like every
other card) is what actually lines them up. Also swapped the generic
"E-Mail-Adresse" placeholder for one that states the purpose.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 07:17:14 +00:00
Marco 82c1b1fd43 Match sold-out card height to in-stock siblings
NotifyMeForm was two stacked rows (email input + full-width button),
making an out-of-stock ProductCard taller than its in-stock siblings.
Redesigned as a single input with the submit control embedded inside it
(same py-3 as AddToCartInlineButton's own button, so the row height
matches exactly), and ProductCard now skips its reserved-height
low-stock line entirely for a fully-out-of-stock product, since that
line can never apply there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 07:14:49 +00:00
Marco c8f231baa6 Dynamic AGB address, real shipping costs on /versand instead of hardcoded constants
- AGB: name/address/email now come from company-settings via new
  VertragspartnerBlock (mid-document, see LegalPages.ts's contentPart2).
- VersandSections/VersandModal/CartContent/CheckoutContent/versand page:
  shipping cost and free-shipping threshold now come from the real
  ShippingMethods data (already fetched elsewhere for checkout), not the
  hardcoded lib/shipping.ts constants — those had already drifted from
  the real Payload values once. lib/shipping.ts deleted, nothing left to
  export.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 06:53:12 +00:00
Marco 9617478b0d Add mobile swipe gallery, shared ProductCard, dynamic Datenschutz address, GSC verification
- ProductGallery: touch swipe (left/right) now navigates slides on mobile,
  previously click-only.
- New shared ProductCard component used by ProductGrid/RelatedProducts/
  MerklisteGrid — product title is now the card's link (replaces the
  separate "Mehr erfahren" line), consistent aspect ratio across all
  three grids, more compact cards.
- Datenschutz: name/address/email now come from company-settings via a
  new VerantwortlicherBlock, same single-source pattern as Impressum's
  AnbieterAngaben — no more hand-typed address to keep in sync.
- layout.tsx: render googleSearchConsoleVerification via Next's native
  verification.google metadata field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 05:38:55 +00:00
Marco 23f8efcc2b Fix Klaro backdrop only covering a column, not full viewport
Root cause: .cookie-modal is the full-viewport wrapper, NOT the
dialog box — the previous centering fix wrongly applied
position/transform to it. Adding a transform to that wrapper created
a new containing block for its position:fixed children, so .cm-bg
(the backdrop) started positioning itself relative to the now-
shrunken/centered wrapper instead of the real viewport — the dark
overlay only covered a 640px-wide column (screenshot-confirmed
2026-08-02) instead of the whole screen.

Moved all sizing/position/shadow overrides to .cm-modal.cm-klaro (the
actual dialog box, a sibling of .cm-bg — both children of the
untouched full-screen .cookie-modal). Also fixed several selectors
that were quietly matching nothing: .cn-body doesn't exist in the
settings modal (that's .cm-header/.cm-body), and the footer button
row is .cm-footer-buttons, not .cm-buttons (that class belongs to the
small notice/context-notice components only). Restyled the modal's
close button and structural padding to match, now that the real
selectors are confirmed against klaro's own consent-modal.jsx source.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 22:20:16 +00:00
Marco 6e6f273524 Match Klaro modal backdrop color to NewsletterModal's own overlay
Klaro's default .cm-bg is plain black at 50% opacity — functionally
correct (standard modal dimming, confirmed intentional, not a bug)
but a different shade from this site's own established modal
backdrop. Now matches NewsletterModal.tsx's
rgba(134,134,134,0.9) exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 22:15:15 +00:00
Marco 3336ce77c1 Fix Klaro settings modal losing horizontal centering
The unconditional max-width:640px override collided with Klaro's own
breakpoint-dependent centering (margin:auto only applies above an
internal JS-set breakpoint; below it the modal is fixed/width:100%
with no left/margin, so the wider modal rendered flush-left instead
of centered). Explicit left:50%+translate(-50%,-50%) centers on both
axes regardless of which of Klaro's own branches is active.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 22:12:12 +00:00
Marco 90c52687bf Fix invisible service descriptions, broken empty-translation glitch; redesign modal list
Real bugs, both confirmed via screenshot 2026-08-02:
- dark3 was set to a near-white cream tone assuming background-only
  use, but Klaro's .cm-list-description rule uses it as a TEXT color
  — every service description was almost invisible. Changed to
  --color-text-muted.
- The service.disableAll.description="" override to hide Klaro's
  boilerplate toggle-all text broke its own t() lookup, rendering a
  literal "[missing translation: de/service/disableAll/description]"
  string instead — worse than the original. Reverted, hidden via a
  plain CSS display:none on .cm-toggle-all .cm-list-description
  instead.

Also a real redesign pass on the settings modal's service/purpose
list (wider modal, row separators, bigger/clearer toggle switches,
distinct footer) — was previously only reachable via the notice's own
generic styling, still visibly "default library layout" per Marco's
own read.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 22:09:28 +00:00
Marco 7ef6d2652b Klaro polish: remove redundant toggle-all text, responsive button rows, real per-service descriptions
- Empties Klaro's own "Mit diesem Schalter..." boilerplate under the
  modal's per-service toggle-all switch — redundant next to a plainly
  labeled switch.
- .cn-ok/.cn-buttons/.cm-buttons now explicit flex-wrap rows (side by
  side until they genuinely don't fit, then wrap) instead of relying
  on Klaro's own fragile inline-block flow.
- Every tracking-codes provider gets a real description in the modal's
  service list (was title-only) — what it does and, where publicly
  documented and stable, the actual cookie names/lifetimes (_ga/
  _ga_<container-id> for GA4, _fbp/fr for Facebook Pixel). GTM/Maps
  descriptions are deliberately non-specific about exact cookies —
  neither has a fixed, publicly enumerable list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 22:04:13 +00:00
Marco ac6c3d6b71 Add GoogleMapsEmbed.tsx — consent-gated Maps embed, prepared but unused
Uses Klaro's own built-in contextual-consent mechanism (not
loadTrackingCode.ts's script-injection path) — the iframe renders
with data-name="google-maps" and its real src; Klaro's DOM scan finds
it after mount and blanks/restores src based on that service's
consent state, showing its own placeholder (styled via
KlaroConsentManager.tsx's KlaroTheme) in the meantime.

No map embedded anywhere in the app yet — this is prep work only, per
explicit request ahead of an actual page needing one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 21:58:19 +00:00
Marco 417bbd430e Add product image gallery, harden Klaro border reset, remove attribution link
ProductGallery.tsx (main image + thumbnail strip) wired into
TodoKartenHero.tsx, only replacing the static hero photo once a
product actually has extra gallery photos — no visual change
otherwise. Backend field: Products.gallery (see payload repo).

Klaro's hard black border was still showing after the previous
color-only pass — the targeted border:none rule wasn't enough, so
this blanket-resets border/outline on every .klaro descendant and
re-adds only the shadow this theme actually wants. Also disables the
"Realisiert mit Klaro!" attribution link (BSD-3-Clause has no
on-page-attribution requirement, Klaro's own disablePoweredBy flag
covers this cleanly).

Updates the frontend README with the gallery + Klaro fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 21:41:23 +00:00
Marco f6b001dd11 Restyle Klaro to actually match the brand, not just its accent colors
CSS-var overrides alone (colors, corner position) still left Klaro's
own border/shadow/spacing/typography, which read as an obviously
bolted-on library widget next to this site's hand-designed
components. New KlaroTheme component overrides Klaro's real DOM
classnames directly (confirmed against kiprotect/klaro's own scss
source) — kills the default border, adds this site's own soft-shadow
card look, restyles every button, uses the site's actual serif/sans
font pairing. Also warms up the notice/modal copy.

Updates both READMEs with the tracking-codes/Klaro/back-in-stock/
settings-search/DHL-shipment-label work from this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 21:20:28 +00:00
Marco a8ab3e4f44 Fix Klaro's remaining default-blue accent (Einstellungen button, modal links)
green1/red1 covered the notice's accept/decline buttons, but blue1/
blue2 (the modal's "Einstellungen" button + in-modal links) were
still Klaro's stock blue — only visible once the modal itself opens,
easy to miss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 21:12:14 +00:00
Marco 72b7bc629c Restyle Klaro banner to brand + add persistent reopen button
Was full-width ('wide' theme) with Klaro's default green/dark
palette, neither matching the brand. Now: compact bottom-left corner
notice (~380px), brand colors via Klaro's CSS-var overrides
(dark1/light1 etc. — confusingly named, they're background/text, not
a dark-mode switch), neutral gray decline button instead of red.

Also adds a persistent bottom-left cookie icon (KlaroConsentManager.tsx)
so a visitor can reopen the consent manager any time via Klaro.show(),
not just on first visit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 21:07:26 +00:00
Marco 43ab5f2407 Swap Zahlungsstatus/Zahlungsart order in order detail view
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 20:41:49 +00:00
Marco 537543bf91 Replace custom cookie banner with Klaro (open-source, self-hosted CMP)
Swaps the hand-rolled CookieBanner.tsx/useConsent.ts/TrackingScripts.tsx
for kiprotect/klaro — brings a real per-service consent list and
bundled German UI translations that a custom implementation would
have had to build from scratch (per user feedback that a proper CMP
is worth it over a purely custom binary accept/reject banner).

klaroConfig.ts builds Klaro's config dynamically from the existing
tracking-codes backend collection (one Klaro "service" per row,
grouped by consentCategory as its purpose). loadTrackingCode.ts is
the actual script-injection side effect, wired in via each service's
`callback(consent)` — same GA4/Facebook-Pixel/GTM/custom loader logic
TrackingScripts.tsx had, just triggered imperatively instead of
declaratively rendered. Brand color applied via Klaro's CSS custom
property overrides (styling.green1 etc.), not custom SCSS.

No @types/klaro package exists — types/klaro.d.ts declares only the
small surface actually used.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 20:33:10 +00:00
Marco 259002f5c0 Add product thumbnail to back-in-stock email, left-align notify button text
renderBackInStockHtml() (and sendBackInStockEmails.ts on the backend)
now show a small product image above the product name, when the
product has one. Also left-aligns NotifyMeForm's button text to match
the email input's own left-aligned placeholder — was centered, read
as inconsistent next to it.

Also adds the klaro dependency, ahead of swapping the custom cookie
banner for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 20:19:59 +00:00
Marco 87388479de Fix out-of-stock CTA regressions: grid alignment, preview accuracy, label length
- Replace the invisible reserved-space trick with items-start on every
  product grid (ProductGrid/MerklisteGrid/RelatedProducts) — the
  reservation looked worse in practice (visible dead space under
  in-stock cards' buttons) than letting an out-of-stock card simply be
  taller than its siblings.
- New renderBackInStockHtml() in lib/emailTemplates.ts, used by the
  Live Preview instead of the generic order-status renderer — that one
  showed a fake order number and "Bestellung ansehen", neither of
  which apply to a back-in-stock mail (no order exists). CTA is now
  "Zum Produkt", matching the real backend send.
- Shortened NotifyMeForm's button label ("Benachrichtigen") — the
  longer version wrapped to two lines on narrow single-column cards.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 20:12:23 +00:00
Marco 70ee518e1d Ship tracking-codes Phase 2: cookie consent banner + gated script injection
CookieBanner.tsx (equally-weighted Akzeptieren/Ablehnen, TTDSG) +
useConsent() cookie hook + TrackingScripts.tsx render active
tracking-codes rows only once their consentCategory is actually
accepted ('necessary' always renders). Wired into layout.tsx via the
new getTrackingCodes() fetcher. This is what makes the Phase 1
backend collection (tracking-codes) actually usable end to end.

Also reworks NotifyMeForm back to always-visible input+button (better
UX than a collapse-to-reveal step) — the resulting taller CTA is now
reserved on every card via NotifyMeFormReservedSpace, an invisible
twin rendered behind the real button, so an in-stock card's row
height matches an out-of-stock sibling's without the grid's
row-stretch pushing buttons out of alignment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 20:03:11 +00:00
Marco c6b12e9d60 Fix missing back-in-stock email Live Preview (404)
'back-in-stock' was added to the Payload-side email-templates type
but never wired into this repo's own EmailTemplateType union or the
Live Preview page's VALID_TYPES/fallback-heading maps — every other
type is registered in three places, this one was only in one, so
opening its Live Preview 404'd.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 19:57:47 +00:00
Marco d046e5c3bb Fix out-of-stock CTA: replace button instead of stacking, collapse notify form
Two problems in the previous back-in-stock commit: the disabled
"Ausverkauft" button and NotifyMeForm stacked, making out-of-stock
cards visibly taller than in-stock siblings — and since ProductGrid's
cards rely on plain CSS Grid row-stretch for equal card height, that
extra height stretched sibling cards and pushed their own buttons
down (screenshot: "In den Warenkorb" CTAs misaligned across a row).

NotifyMeForm now replaces the button slot entirely when out of stock
(matching the height of a normal button when collapsed), and only
expands to the email input after a click.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 19:55:38 +00:00
Marco 2638515683 Fix NotifyMeForm layout: stack input/button instead of side-by-side
Narrow product cards (shop grid, related products) truncated the
email placeholder when input and button shared a row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 19:52:37 +00:00
Marco 0dcaad8b8c Add back-in-stock notification signup form
NotifyMeForm.tsx replaces the disabled Add-to-cart button's spot once
a product/variant is out of stock — POSTs to the new
/api/stock-notifications route, which forwards to the backend's new
stock-notifications collection. Threaded product.numericId (not the
commerce slug id) into AddToCartButton/AddToCartInlineButton for this,
same split WishlistButton already uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 19:48:00 +00:00
Marco b0df2f13fa Fix shop filter spacing: consistent checkbox gaps, more room below mobile filter bar
CategoryFilter used gap-4 between the category group and the
availability checkbox but gap-2 within the category list itself,
reading as uneven spacing. ProductGrid's <lg filter bar only had
pb-6 before the product grid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 19:01:16 +00:00
Marco 51632b1668 Fix wishlist items not saving: pass customerId into toggleWishlistItem
The create POST omitted the customer relationship field entirely, so
added items never matched getWishlist's customer-scoped query. Also
scope the toggle-off fallback lookup to the current customer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 15:04:35 +00:00
Marco d010a3aff1 Add wishlist button to cart page's related-products cards
RelatedProducts.tsx was the only product-card grid missing the
WishlistButton (ProductGrid.tsx/ProductSpotlight.tsx already had it).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 11:50:31 +00:00
Marco 1ec442cc05 Fix guest cart never reaching the server on login
mergeServerCartIntoLocal() relied on CartSync's own change-triggered
push to upload the merged cart — but if the server cart was empty
(first login on this account) and the local guest cart wasn't, the
merge loop never actually ran (nothing in the server response to
add), so no write/change-event ever fired and CartSync's effect never
pushed anything. The guest cart looked fine on the device that just
logged in, but never reached any other device. Now pushes explicitly
at the end regardless of whether anything changed.
2026-08-01 10:46:57 +00:00
Marco 552718c09a Fix cart pull not syncing removals across devices
pullServerCart() only added/updated matching lines and never dropped
a local line the server no longer had — a removal on one device never
reached another (adding synced, removing didn't). Now replaces the
local cart outright with the server's instead of merging into it,
including down to empty.
2026-08-01 10:40:31 +00:00
Marco ce3ac3e79a Sync the cart to an already-logged-in device, not just at login
Cross-device sync only ever worked one way (local→server on every
change via CartSync) plus a server→local merge at the exact moment of
login/register — a customer already signed in on a second device (no
fresh login action happening) never picked up changes made elsewhere.

Adds pullServerCart(): idempotent (server qty overwrites a matching
local line rather than adding to it, safe to call repeatedly, unlike
mergeServerCartIntoLocal()'s additive merge which only stays correct
right after a login clears the local cart's ambiguity). Triggered on
CartSync's mount and on window focus — covers "open the site while
already logged in" and "switch back to this tab after changing the
cart on another device," without a polling interval.
2026-08-01 10:34:02 +00:00
Marco 56c279b51b Add spacing below the shop's <lg filter bar
pb-2 wasn't enough — the first product card sat almost flush against
the filter controls on mobile/tablet.
2026-08-01 10:30:57 +00:00
Marco 77a9ef27a9 Make Spotlight's wishlist heart hover-only, matching the shop grid
Was always visible in ProductSpotlight.tsx, unlike ProductGrid.tsx's
cards — inconsistent for what's still a single card in a hoverable
context. The `group` ancestor needed for revealOnHover already exists
there (used for the image's hover-zoom effect), so this is just the
prop.
2026-08-01 10:28:51 +00:00
Marco 45bd321bb0 Fix the second "Weiter einkaufen" arrow (cart with items) missed earlier
The previous replace_all only matched the empty-cart instance's exact
indentation — this one, inside the has-items branch, was a level
deeper and kept the raw Unicode "←" (aria-hidden), still hitting the
mobile font-fallback vertical-metrics bug ArrowLeftIcon.tsx exists to
avoid.
2026-08-01 08:39:10 +00:00
Marco 9a0729adef Document product categories and the new /shop filter sidebar
Products table gains the categories field; Search & filters section
covers the price slider, category checkboxes, availability toggle,
the blog's client-side category filter, and the RevealGroup key fix
both grids needed to stop going blank after a filter change.
2026-08-01 08:37:52 +00:00
Marco cc17311d03 Show product category as an eyebrow label on product cards
Same treatment as the blog cards' category line (Blog.tsx) — small
uppercase text-muted line above the product name, applied
consistently across all three card grids (shop, Merkliste, cart's
related products). Omitted entirely for an uncategorized product
rather than showing an empty line.
2026-08-01 08:36:53 +00:00
Marco 83de7df089 Fix shop grid going blank after applying a filter
Same RevealGroup stuck-at-opacity-0 bug as the blog's category
filter (see f95feaf) — whileInView only fires once per component
instance, and a price/category/inStock filter change re-renders the
same /shop page in place, so RevealGroup never naturally remounts
between filter states. Keying it on the actual filtered product set
forces a remount, restarting the viewport tracking each time.
2026-08-01 08:33:51 +00:00
Marco aabfad3541 Replace Unicode "←" with an SVG icon in cart's "Weiter einkaufen" links
Same font-fallback bug ArrowRightIcon.tsx already documents and fixed
for "→" elsewhere (Tools.tsx, Blog.tsx, ProductGrid.tsx) — the site's
custom web fonts don't cover U+2190, so it fell back to a system font
whose vertical metrics sit visibly low on mobile. Missed in that
earlier pass since CartContent.tsx uses the left-pointing twin.
2026-08-01 08:32:19 +00:00
Marco 557f6a1abc Add product categories + shop sidebar filters, fix CTA button alignment
Products now carry an optional categories relationship (backend:
new product-categories collection mirroring the blog's categories
pattern, migration applied and deployed). The shop page gains a left
sidebar (styled like AccountNav) with a dual-handle price slider,
category checkboxes, and an availability toggle — all instant-apply
via searchParams, same union-filter semantics as the blog's category
chips. Uncategorized products always match every category filter
rather than disappearing.

Also fixes CTA buttons sitting at different heights across sibling
cards when one product's title wraps to two lines — MerklisteGrid.tsx
and RelatedProducts.tsx get the same h-full/flex-1 spacer pattern
ProductGrid.tsx already used.
2026-08-01 08:15:58 +00:00
Marco f95feafb1a Fix blog post list staying invisible after re-filtering categories
RevealGroup's whileInView fires once per component instance. Since a
category-filter navigation re-renders the same /blog page in place
(only searchParams changes, no full remount), and rest.length stays
> 0 across most filter transitions, RevealGroup itself never
naturally unmounts — so once it already fired "show" for one
filter's list, newly swapped-in RevealItems (different post ids)
mounted into an already-settled parent with no reason to re-fire the
reveal trigger, staying stuck at opacity 0 forever. Keying
RevealGroup (and the featured Reveal) on the actual rendered post
set forces a remount whenever the filtered posts change, restarting
the viewport tracking each time.
2026-08-01 06:29:53 +00:00
Marco e49dacf057 Fix search overlay CSS trap and blog filter navigation race
SearchOverlay was rendered as a <header> descendant, so the header's
conditional backdrop-blur-md (once scrolled) made it the containing
block for the overlay's fixed positioning, clipping the opaque
background to the header's height and letting page content show
through underneath. Now rendered as a header sibling, same pattern
already used for NewsletterModal.

Blog category chips are now a client component gating navigation
behind useTransition, disabling the chips while a navigation is
pending so rapid clicks can't fire overlapping RSC navigations that
commit out of order and briefly show an empty result.
2026-08-01 06:17:09 +00:00
Marco 5c4a0e011d Die Sieben: fix 3-finger hand icon, expand Worum-es-geht copy
- IconHand had 2 finger segments + a thumb curve (3 digits total,
  visibly wrong for an open-hand icon) — rebuilt as 4 finger capsules +
  thumb using simple rounded-rect shapes instead of hand-tuned path
  arcs, verified visually via a local render before applying.
- Worum-es-geht was too thin for a page opener — expanded with a
  concrete, specific opening (not a generic "in a busy world" framing)
  and more of what the monthly invitations actually look like in
  practice, still in first person.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
2026-07-31 23:16:35 +00:00
Marco aa491aa5fd Add /die-sieben — rebuilt from the old WordPress page, brand-voice copy
Free monthly ritual page, modeled on /todo-cards' conventions
(Reveal/RevealGroup, breadcrumb + text-display heading) but without a
product-style hero — this isn't a paid product, so no photo/price/buy
CTA. The WordPress original's monthly-changing "aktuelle Ausgabe",
printable template, and archive have no content model in this codebase
yet (nothing like a `Posts` collection for "editions") — this ships the
evergreen concept page only, with the existing Newsletter signup
standing in as the "stay in the loop" mechanism. Deferred to a memory
note rather than blocking this page on designing a new collection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
2026-07-31 23:09:24 +00:00
Marco 8b636bef25 Add the same badge pulse animation to the wishlist icon as the cart icon
Same mount-guard pattern as CartLink's own pulse fix — useWishlist()'s
count only fills in after its client-side fetch resolves, so without the
guard this would have pulsed on every page load too, not just a real
add/remove during the session. Fires on either direction (add or
remove) since the wishlist has no separate "flying" animation like the
cart's ball-to-icon effect to lean on instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
2026-07-31 22:54:42 +00:00
Marco 9f61322604 Match Blog and Shop heading sizes back to text-display
Blog's own hero already used text-display; ShopHeader.tsx used the
smaller text-h-feature, reading as inconsistent next to it. Both now
use text-display.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
2026-07-31 22:52:22 +00:00
Marco b88a8edd35 Fix legal-page autolink color, add Blog breadcrumb, revert breadcrumb color
- RichText.tsx: Lexical's auto-detected "autolink" nodes (a typed-out
  email/URL, as opposed to an editor-inserted link) fell through to
  Payload's unstyled default converter — only "link" was overridden.
  AGB's Vertragspartner email rendered as plain black text because of
  this. Both node types now get the same text-brand/hover:underline
  treatment.
- Breadcrumb color revert: the breadcrumb Startseite links across the
  4 legal pages/shop should stay their original hover:text-brand
  treatment — only the actual content/contact links needed the
  always-brand-colored style, not page-level breadcrumbs.
- /blog was the one page missing both a breadcrumb and the
  text-h-feature heading size every other section page (/shop, legal
  pages) already uses — added to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
2026-07-31 22:50:48 +00:00
Marco 7c26bf7b7c Fix cart-badge false pulse, blog hero-card misattribution, legal breadcrumb color
- Cart badge pulsed on every page load/reload, not just real cart
  changes — useCartCount's SSR snapshot is always 0, so hydration alone
  satisfied the "count increased" check. Skip the very first effect run.
- WishlistButton: touch-device smaller/subtler sizing now applies
  regardless of revealOnHover (e.g. the homepage spotlight heart), not
  just the shop grid's hover-reveal mode.
- /blog: the big featured-style hero card was applied to whatever post
  sorted first post-filter, even when that post isn't actually the
  `featured` one (a category filter can exclude the real featured post
  entirely). Now gated on the post's own `featured` flag.
- Legal pages' "Startseite" breadcrumb link now matches the same
  text-brand/hover:underline treatment already used elsewhere on these
  pages (e.g. Impressum's Anbieter email link), instead of staying muted
  until hover.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
2026-07-31 22:46:24 +00:00
Marco bf6c5d079a Fix order-filter dropdowns, restructure Konto identity line, drop blog reset link
- CustomSelect.tsx: mousedown on an option blurred the trigger button
  before the click landed, unmounting the list before anything could be
  selected — add preventDefault on the list's mousedown to stop that.
- KontoShell no longer renders "Eingeloggt als ..." above each page's
  content; each page renders its own title then AccountIdentity right
  below it, and ProfileForm's now-duplicate email/Kundennummer line is
  removed.
- Blog category filter: drop the separate "Zurücksetzen" link — clicking
  an active category badge again already deactivates it.
- Werkzeuge cards: more vertical gap between stacked cards below sm:.
- Legal pages' "Stand: ..." line is now derived from the LegalPages doc's
  own updatedAt instead of a hand-typed string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
2026-07-31 22:29:36 +00:00
Marco d018d87e25 Fix account-nav feedback, blog filter position, and small icon polish
- Remove Merkliste from AccountNav — the Navbar's own wishlist icon already covers it, a second nav entry was redundant.
- Fix duplicate "Mein Profil" heading (ProfileForm already renders its own; KontoShell no longer adds a second one).
- "Eingeloggt als ..." now renders once in KontoShell, consistently on every /konto/* page, instead of only on the orders page.
- Blog category filter chips moved into the hero's own text column (was a separate bar below the entire hero including the photo) — visible immediately on every width instead of requiring a scroll past the hero image first.
- ArrowRightIcon's arrowhead wings shortened (looked like a generic oversized chevron at full length).
- Search overlay's "Esc" text button replaced with an X icon; the actual Escape keyboard shortcut is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 22:13:08 +00:00
Marco f54ff267a7 Document the shared-modules section pointing to @einfach-produktiv/invoicing
Notes what moved into the shared package (VIES, VAT-ID, PLZ, carrier-tracking) and why — this repo's README previously didn't call out the modularization/dedup pass at all, only individual file references scattered through other sections.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 22:01:29 +00:00
Marco 6f033d52b5 Add persistent account navigation (sidebar/tabs); fix CTA arrow on Android
Konto-Aktionen links (profile, wishlist, logout) used to live at the bottom of the orders page's own content — unreachable once the order list got long enough. New KontoShell + AccountNav give every /konto/* page a persistent sidebar (sm+) / tab bar (mobile) instead, always reachable regardless of list length. The order filters also collapse behind a "Filter" toggle on mobile now, since the tab bar above them left little room for 3 full-width dropdowns.

Also: replaced the Unicode "→" arrow in CTA links (Tools.tsx, Blog.tsx, ProductGrid.tsx) with an SVG icon — the glyph isn't covered by the site's custom fonts, so browsers fall back to a system font per-platform; confirmed sitting visibly low relative to the label text on Android/Chrome (Galaxy S22), not reproducible in desktop Chromium. An SVG has no font-fallback path, so it renders identically everywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 21:58:04 +00:00
Marco 92727ff22a Keep icon touch targets full-size; shrink logo instead; fluid nav gap
Revert the 40px icon-shrink below 375px (hard to tap accurately) and reclaim that space from the logo instead (76-130px depending on width, vs the always-181px controls). Re-verified overflow-free at 320px via Playwright with all 4 icons at full 44px.

Also: wishlist heart's touch-friendly smaller/subtler size now applies to already-wishlisted (filled) hearts too, not just not-yet-wishlisted ones. The wishlist page's login redirect now returns to the wishlist instead of the generic account/orders overview. Desktop nav-link gap now scales fluidly between 1024-1300px instead of a fixed 48px that felt too wide right where nav labels have the least room.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 21:20:01 +00:00
Marco 1a3a8ba20e Show search/wishlist icons on all screen widths, not just sm+
Both were deliberately hidden below 640px to avoid icon-row overflow. Verified via an actual Playwright viewport sweep (320-640px) that simply un-hiding them did overflow — the header's fixed 181px logo + 32px padding left no room. Fixed properly: logo shrinks to 130px and padding drops to px-4 below sm, plus a 40px icon-size step below 375px. Re-verified overflow-free down to 320px.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 21:08:11 +00:00
Marco b144758b82 Restore a light background on mobile wishlist hearts
Fully transparent made the heart invisible against some product photos — keeps it smaller than the default but with a semi-transparent (60%) background circle instead of none, and stops dimming the icon itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 20:53:15 +00:00
Marco 92c6953724 Make mobile wishlist hearts smaller/subtler instead of full-size everywhere
Full-size, always-visible hearts on touch devices recreated the exact "a heart on every card" visual noise revealOnHover exists to avoid. Touch devices now get a smaller, backgroundless, slightly transparent heart instead — present and tappable everywhere, but visually quiet. Desktop hover-reveal behavior is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 20:49:25 +00:00
Marco 9bf6e3f4f2 Fix wishlist heart invisible on mobile product grids
revealOnHover relied purely on group-hover/group-focus-within, both mouse/keyboard-only pseudo-classes with no reliable touch equivalent — a tap on a card never reliably triggers group-hover the way a mouse hover does, leaving the heart permanently hidden on touch devices. Adds pointer-coarse:opacity-100 so it's always visible there; desktop hover-reveal behavior is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 20:43:40 +00:00
Marco a077adea0f Rewrite README as current-state reference, drop changelog narrative
Removes all dated section headings (2026-07-24 through 2026-07-31) and inline dated asides, folding still-true facts into the evergreen structure (Pages, Cart & checkout, Orders & customer accounts, etc.). Also fixes references to app/lib/vies.ts/vatId.ts/tracking.ts, which moved into @einfach-produktiv/invoicing. Git history is the actual changelog. 2375 -> 1798 lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 18:06:48 +00:00
Marco b6a826dc46 Replace hand-duplicated VIES/VAT-ID/PLZ/tracking logic with shared package
Deletes app/lib/vies.ts, vatId.ts, tracking.ts in favor of the newly unified @einfach-produktiv/invoicing modules — fixes the actual PLZ inconsistency (this repo already validated per-country digit counts; the backend hardcoded German-only) rather than just deduplicating code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 15:41:01 +00:00
Marco 230b8ebaad Mark already-purchased items on the wishlist instead of auto-removing
A customer often wishlists something specifically to buy it again (gifts, repurchases) — silently removing it after purchase would defeat that. /konto/merkliste now shows a dimmed image + "Gekauft am [date]" badge instead, derived read-only from the customer's own orders (cancelled/returned orders excluded). Removal stays manual.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:51:10 +00:00
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
148 changed files with 8037 additions and 1573 deletions
+611 -325
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
import { headingId } from "../../components/RichText";
import type { CompanySettings } from "../../lib/payload";
import type { TOCSection } from "../../components/SectionTOC";
// Renders "2. Vertragspartner" straight from company-settings, same
// single-source-of-truth pattern as Impressum's AnbieterAngaben.tsx and
// Datenschutz's VerantwortlicherBlock.tsx — this used to be hand-typed
// name/address/email baked into the AGB richText (seed-agb.ts on the
// Payload side), and had already drifted from the real company-settings
// values once (the richText's placeholder "Björn Wendt"/"Musterstraße
// 12" never got updated when the real address was set). Sits mid-document
// (section 1 "Geltungsbereich" comes before it), which is why AGB's
// content is split into `content` (section 1) + `contentPart2` (sections
// 3 onward) rather than just prepending this block like Datenschutz did —
// see LegalPages.ts's `contentPart2` field comment.
export function vertragspartnerHeadings(): TOCSection[] {
return [{ id: headingId("2. Vertragspartner"), title: "2. Vertragspartner" }];
}
function Heading({ children }: { children: string }) {
return (
<h2
id={headingId(children)}
className="font-semibold text-h-small text-text-primary mt-2 scroll-mt-32 first:mt-0"
style={{ fontFamily: "var(--font-lora)" }}
>
{children}
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
</h2>
);
}
function P({ children }: { children: React.ReactNode }) {
return <p className="text-body text-text-body">{children}</p>;
}
export function VertragspartnerBlock({ seller }: { seller: CompanySettings }) {
return (
<div className="flex flex-col gap-4 w-full">
<Heading>2. Vertragspartner</Heading>
<P>Der Kaufvertrag kommt zustande mit:</P>
<div className="flex flex-col gap-1">
<P>
<strong>einfach produktiv. {seller.sellerName}</strong>
</P>
<P>
<strong>
{seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity}
</strong>
</P>
<P>
<strong>
E-Mail: <a href={`mailto:${seller.sellerEmail}`} className="hover:underline">{seller.sellerEmail}</a>
</strong>
</P>
</div>
</div>
);
}
+25 -6
View File
@@ -8,7 +8,9 @@ import { TrustRow } from "../components/TrustRow";
import { RichText, extractHeadings } from "../components/RichText";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
import { getLegalPage, getCompanySettings } from "../lib/payload";
import { formatMonthYear } from "../lib/format";
import { VertragspartnerBlock, vertragspartnerHeadings } from "./components/VertragspartnerBlock";
export const metadata: Metadata = {
title: "AGB",
@@ -18,8 +20,16 @@ export const metadata: Metadata = {
export default async function AgbPage() {
const { isEnabled: isPreview } = await draftMode();
const page = await getLegalPage("agb", { draft: isPreview });
const headings = page ? extractHeadings(page.content) : [];
const [page, seller] = await Promise.all([getLegalPage("agb", { draft: isPreview }), getCompanySettings()]);
// Section 1 ("Geltungsbereich") comes first from `content`, THEN
// "2. Vertragspartner" (dynamic, sits between two CMS-driven halves —
// see LegalPages.ts's `contentPart2` comment), then the rest from
// `contentPart2`.
const headings = [
...(page ? extractHeadings(page.content) : []),
...vertragspartnerHeadings(),
...(page?.contentPart2 ? extractHeadings(page.contentPart2) : []),
];
return (
<>
@@ -36,7 +46,7 @@ export default async function AgbPage() {
>
Allgemeine Geschäftsbedingungen
</p>
<p className="text-body text-text-muted">Stand: Juli 2026</p>
{page && <p className="text-body text-text-muted">Stand: {formatMonthYear(page.updatedAt)}</p>}
</Reveal>
{/* MobileSectionTOC — below lg: only, see SectionTOC.tsx's own
@@ -68,9 +78,18 @@ export default async function AgbPage() {
</div>
</div>
<div className="w-full lg:flex-1 min-w-0">
<div className="w-full lg:flex-1 min-w-0 flex flex-col gap-8">
{page ? (
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
<>
{isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />}
{/* Name/Adresse/E-Mail kommen direkt aus company-settings,
nicht aus der CMS-Richtext — single-sourced, gleiche
Begründung wie Impressum/Datenschutz. */}
{seller && <VertragspartnerBlock seller={seller} />}
{page.contentPart2 ? (
isPreview ? <LiveRichText initialContent={page.contentPart2} /> : <RichText content={page.contentPart2} />
) : null}
</>
) : (
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
)}
+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 } : {}),
});
}
+67 -12
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, updateCustomerProfile } from "../../../lib/customerAuth";
import { normalizeVatId, isValidVatId } from "../../../lib/vatId";
import { normalizeVatId, isValidVatId } from "@einfach-produktiv/invoicing";
export async function GET() {
const session = await getSessionCustomer();
@@ -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, session.customer.id, 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 });
}
+32 -6
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";
@@ -8,8 +8,8 @@ import { fetchProductsBySlug } from "../../lib/productsServer";
import { describeBundleContents } from "../../lib/bundleContents";
import { sendCriticalAlert } from "../../lib/alertAdmin";
import { sendOrderConfirmationEmail } from "../../lib/orderEmail";
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
import { checkVatIdViaVies } from "../../lib/vies";
import { normalizeVatId, isValidVatId } from "@einfach-produktiv/invoicing";
import { checkVatIdViaVies } from "@einfach-produktiv/invoicing/vies";
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
import { upsertNewsletterContact } from "../../lib/brevo";
import { paymentProvider, isPaymentTestMode } from "../../lib/payments";
@@ -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);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { normalizeVatId, isValidVatId } from "../../../lib/vatId";
import { checkVatIdViaVies } from "../../../lib/vies";
import { normalizeVatId, isValidVatId } from "@einfach-produktiv/invoicing";
import { checkVatIdViaVies } from "@einfach-produktiv/invoicing/vies";
// Called from CheckoutContent.tsx on the USt-IdNr. field's blur, whenever
// the billing country is Österreich — the only cross-border-EU option this
+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 });
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { isValidEmail } from "../../lib/email";
import { createStockNotification } from "../../lib/stockNotifications";
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const email = typeof body?.email === "string" ? body.email.trim() : "";
const productId = Number(body?.productId);
const variantName = typeof body?.variantName === "string" ? body.variantName : "";
if (!isValidEmail(email)) {
return NextResponse.json({ ok: false, reason: "Bitte gib eine gültige E-Mail-Adresse ein." }, { status: 400 });
}
if (!Number.isInteger(productId) || productId <= 0) {
return NextResponse.json({ ok: false, reason: "Ungültiges Produkt." }, { status: 400 });
}
const result = await createStockNotification(email, productId, variantName);
if (!result.ok) return NextResponse.json(result, { status: 500 });
return NextResponse.json({ ok: true });
}
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>
+99 -23
View File
@@ -4,7 +4,8 @@ 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 { BlogCategoryFilter } from "../components/BlogCategoryFilter";
import { getBlogPosts, getBlogFilterEnabled } from "../lib/payload";
import { formatDate } from "../lib/format";
export const metadata: Metadata = {
@@ -20,9 +21,38 @@ export const metadata: Metadata = {
},
};
export default async function BlogOverviewPage() {
const posts = await getBlogPosts(100);
const [featured, ...rest] = posts;
// 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);
}
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)));
// getBlogPosts sorts "-featured,-publishedAt", so index 0 IS the actual
// featured post when the unfiltered list is shown — but a category
// filter can (and often will) exclude it entirely, in which case index 0
// is just the newest matching post, not an editorially featured one. It
// still doesn't deserve the big hero-card treatment (implies "the
// featured post", not "whatever sorted first"), so that layout is gated
// on the post's own `featured` flag, not on array position.
const [first, ...restAll] = posts;
const featured = first?.featured ? first : undefined;
const rest = featured ? restAll : posts;
return (
<>
@@ -30,8 +60,18 @@ 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]">
{/* Breadcrumb + text-h-feature heading — same treatment as
/shop's ShopHeader.tsx and every legal page, this hero was
the one page missing both (its own bespoke photo-bleed
layout predates that convention being established
elsewhere). */}
<p className="flex items-center gap-2 text-body-sm text-text-muted">
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
<span></span>
<span className="text-text-primary">Blog</span>
</p>
<p
className="font-semibold text-display text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
@@ -41,35 +81,55 @@ export default async function BlogOverviewPage() {
<p className="text-body text-text-muted">
Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.
</p>
{/* Moved into the hero's own text column (was a separate full-
width bar below the entire hero, including the photo) — on
mobile especially (text stacks above the photo here), that
pushed the filters below a full extra screen's worth of
hero image before they were even visible. Living right
under the description keeps them in view immediately, on
every width, no scrolling past the photo needed. */}
{allCategories.length > 1 && (
<BlogCategoryFilter allCategories={allCategories} activeCategories={activeCategories} />
)}
</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>
{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.
// relative z-10 keeps it stacked above the hero's own photo.
<Reveal delay={0.1} className="relative z-10 w-full px-[var(--layout-padding-x)] -mt-8 pb-4">
// key'd for the same reason as RevealGroup below — guards against
// the same stuck-at-opacity-0 failure mode if a future filter
// combination ever swaps in a *different* featured post without
// an intervening moment where `featured` was falsy.
<Reveal key={featured.id} 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 items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{featured.category}</span>
<div className="flex flex-col justify-center gap-3 p-8 sm:p-12 min-w-0">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span className="whitespace-nowrap">{featured.categories.join(", ")}</span>
<span></span>
<span>{featured.readTime} Min</span>
<span className="whitespace-nowrap">{featured.readTime} Min</span>
<span></span>
<span className="uppercase">{formatDate(featured.publishedAt)}</span>
<span className="uppercase whitespace-nowrap">{formatDate(featured.publishedAt)}</span>
</div>
<p
className="font-semibold text-h-section text-text-primary"
@@ -95,7 +155,23 @@ export default async function BlogOverviewPage() {
)}
{rest.length > 0 && (
<RevealGroup className="flex flex-col w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] py-6 divide-y divide-border">
// key'd on the rendered post set — RevealGroup's `whileInView`
// only fires once per component instance (viewport.once=true),
// and a category-filter change re-renders this same page
// component in place (only `searchParams` differs, no full
// remount). Since `rest.length > 0` stays true across most
// filter transitions, RevealGroup itself never naturally
// unmounts, so once it has already fired "show" for one
// filter's list, freshly swapped-in RevealItems (new post ids)
// mount into an already-settled parent that has no reason to
// re-fire the reveal trigger — they were stuck at opacity 0
// forever, reported as the list appearing blank after refiltering.
// Forcing a remount on every distinct post set restarts
// RevealGroup's viewport tracking from scratch each time.
<RevealGroup
key={rest.map((post) => post.id).join(",")}
className="flex flex-col w-full max-w-[80rem] mx-auto px-[var(--layout-padding-x)] py-6 divide-y divide-border"
>
{rest.map((post) => (
<RevealItem key={post.id} className="py-8 first:pt-0 last:pb-0">
<Link href={`/blog/${post.slug}`} className="group flex flex-col sm:flex-row gap-6 items-start">
@@ -105,12 +181,12 @@ 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>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span className="whitespace-nowrap">{post.categories.join(", ")}</span>
<span></span>
<span>{post.readTime} Min</span>
<span className="whitespace-nowrap">{post.readTime} Min</span>
<span></span>
<span className="uppercase">{formatDate(post.publishedAt)}</span>
<span className="uppercase whitespace-nowrap">{formatDate(post.publishedAt)}</span>
</div>
<p
className="font-semibold text-h-small text-text-primary"
+108 -51
View File
@@ -7,12 +7,13 @@ 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";
import { VersandModal } from "../../components/VersandModal";
import { VatBreakdown } from "../../components/VatBreakdown";
import { ArrowLeftIcon } from "../../components/ArrowLeftIcon";
import { FreeShippingBanner } from "./FreeShippingBanner";
import type { TrustBadge, ShippingSettings } from "../../lib/payload";
@@ -77,7 +78,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);
@@ -163,22 +164,28 @@ export function CartContent({
href="/shop"
className="flex gap-2 items-center text-text-primary hover:text-brand transition-colors"
>
<span aria-hidden></span>
<ArrowLeftIcon />
<span className="font-bold text-body-sm">Weiter einkaufen</span>
</Link>
</Reveal>
) : (
<>
<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);
@@ -221,13 +228,25 @@ export function CartContent({
sm: once the row layout kicks in and the image sits
beside the text instead. */}
<div className="relative w-full aspect-square sm:size-[9.375rem] sm:shrink-0 rounded-sm overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 640px) 150px, 100vw"
className="object-cover"
/>
{product.href ? (
<Link href={product.href} aria-label={product.name} className="block w-full h-full">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 640px) 150px, 100vw"
className="object-cover"
/>
</Link>
) : (
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 640px) 150px, 100vw"
className="object-cover"
/>
)}
{discount !== null && (
<span className="absolute top-2 left-2 rounded-full bg-brand px-2 py-0.5 text-label font-bold text-text-primary">
-{discount}%
@@ -235,13 +254,24 @@ export function CartContent({
)}
</div>
<div className="flex flex-col gap-[0.625rem] items-start flex-1 min-w-0 w-full">
<p
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
{entry.variant ? ` (${entry.variant})` : ""}
</p>
{product.href ? (
<Link
href={product.href}
className="font-semibold text-h-small text-text-primary hover:text-brand transition-colors"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
{entry.variant ? ` (${entry.variant})` : ""}
</Link>
) : (
<p
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
{entry.variant ? ` (${entry.variant})` : ""}
</p>
)}
{/* Independent stacked rows, not grid siblings — no
equal-height pressure from neighboring lines, so a
plain conditional line is enough here. */}
@@ -296,7 +326,7 @@ export function CartContent({
href="/shop"
className="flex gap-2 items-center text-text-primary hover:text-brand transition-colors"
>
<span aria-hidden></span>
<ArrowLeftIcon />
<span className="font-bold text-body-sm">Weiter einkaufen</span>
</Link>
</Reveal>
@@ -391,31 +421,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 +495,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>
@@ -496,7 +547,13 @@ export function CartContent({
</Reveal>
)}
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} shipping={shippingSettings} />
<VersandModal
open={versandOpen}
onClose={() => setVersandOpen(false)}
shipping={shippingSettings}
shippingCost={shippingCost}
freeShippingThreshold={freeShippingThreshold}
/>
</>
);
}
+24 -72
View File
@@ -1,12 +1,10 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { useProducts } from "../../lib/products";
import { formatPrice, discountPercent } from "../../lib/format";
import { effectiveTaxRate } from "../../lib/cartTotals";
import { Reveal } from "../../components/Reveal";
import { AddToCartInlineButton, FEEDBACK_MS } from "../../components/AddToCartInlineButton";
import { ProductCard } from "../../components/ProductCard";
import { FEEDBACK_MS } from "../../components/AddToCartInlineButton";
import { useCart } from "../../lib/cart";
const DISPLAY_COUNT = 3;
@@ -30,7 +28,15 @@ function pickAvailable(allIds: string[], excludeIds: string[], keep: string[], c
return [...keep, ...pickRandom(allIds, [...excludeIds, ...keep], missing)];
}
export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultTaxRate: number; kleinunternehmer: boolean }) {
export function RelatedProducts({
defaultTaxRate,
kleinunternehmer,
wishlistEnabled,
}: {
defaultTaxRate: number;
kleinunternehmer: boolean;
wishlistEnabled: boolean;
}) {
const cart = useCart();
const products = useProducts();
// Cart/checkout resolve any product regardless of `active` (see
@@ -123,18 +129,17 @@ 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]">
{displayProducts.map((product, i) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
// Same "any vs. every" split as ProductGrid.tsx.
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
return (
<div
<div className="grid items-start grid-cols-1 sm:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
{displayProducts.map((product, i) => (
<ProductCard
key={product.id}
product={product}
defaultTaxRate={defaultTaxRate}
kleinunternehmer={kleinunternehmer}
wishlistEnabled={wishlistEnabled}
wishlistRevealOnHover
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 " +
"sm:col-span-4 " +
// 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,67 +147,14 @@ 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"
: ""
: "")
}
>
<div className="relative w-full aspect-[320/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 768px) 320px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{/* Same top-left pill pattern as ProductGrid.tsx/
ProductSpotlight.tsx — position: absolute, so it never
affects this card's height. Only the discount/Ausverkauft
pill lives here now; the low-stock hint moved to a
reserved-height text line below (see the min-h paragraph
under the price) — plain conditional text here is what
broke equal card heights in this grid before. */}
{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>
) : (
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-2 w-full">
<p
className="font-semibold text-h4 text-text-primary w-full"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
</p>
<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>
{/* Always rendered, text conditional — min-h reserves this
line's height in both states so cards in the same row
stay equal height regardless of low-stock status; this
component has no h-full/flex-1 spacer trick like
ProductGrid.tsx to absorb a variable-height line instead. */}
<p className="min-h-[1.05rem] text-label font-bold text-warning">
{anyLowStock ? "Nur noch wenige verfügbar" : null}
</p>
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
</div>
</div>
);
})}
/>
))}
</div>
</section>
);
+4 -3
View File
@@ -4,7 +4,7 @@ import { CartContent } from "./components/CartContent";
import { RelatedProducts } from "./components/RelatedProducts";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
import { getCartTrustBadges, getShippingMethods, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
import { getCartTrustBadges, getShippingMethods, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled } from "../lib/payload";
import { hasActiveDiscountCode } from "../lib/discountServer";
// robots: noindex — transactional page (mirrors a specific shopper's cart
@@ -20,13 +20,14 @@ export const metadata: Metadata = {
};
export default async function CartPage() {
const [trustBadges, shippingMethods, shipping, defaultTaxRate, kleinunternehmer, showDiscountField] = await Promise.all([
const [trustBadges, shippingMethods, shipping, defaultTaxRate, kleinunternehmer, showDiscountField, wishlistEnabled] = await Promise.all([
getCartTrustBadges(),
getShippingMethods(),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
hasActiveDiscountCode(),
getWishlistEnabled(),
]);
// The cart doesn't ask which shipping method the shopper wants yet
@@ -60,7 +61,7 @@ export default async function CartPage() {
showDiscountField={showDiscountField}
/>
</Suspense>
<RelatedProducts defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} />
<RelatedProducts defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} wishlistEnabled={wishlistEnabled} />
<TrustRow />
</main>
<Footer />
@@ -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>
);
}
+219 -87
View File
@@ -7,18 +7,20 @@ 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";
import { PaymentStep } from "./PaymentStep";
import { dispatchAuthChanged } from "../../lib/auth";
import { readCheckoutDraft, writeCheckoutDraft, clearCheckoutDraft } from "../../lib/checkoutDraft";
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
import { normalizeVatId, isValidVatId, isValidPlz, plzInputPattern } from "@einfach-produktiv/invoicing";
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
import { validateEmailFormat } from "../../lib/email";
import type { ShippingMethod, ShippingCountry, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload";
@@ -35,8 +37,7 @@ import type { CustomerProfile } from "../../lib/customerAuth";
// here. `?? 4` only matters if a country somehow isn't in the map at all
// (shouldn't happen — the <select> options are built from the same list).
function plzPattern(country: string, plzDigitsMap: Record<string, number>): string {
const digits = plzDigitsMap[country] ?? 4;
return `\\d{${digits}}`;
return plzInputPattern(plzDigitsMap[country] ?? 4);
}
// Same rules as the pattern/required attributes each field already
@@ -50,7 +51,7 @@ function validateRequired(label: string, value: string): string {
function validateZip(value: string, country: string, plzDigitsMap: Record<string, number>): string {
if (!value.trim()) return "PLZ ist erforderlich.";
const digits = plzDigitsMap[country] ?? 4;
return new RegExp(`^\\d{${digits}}$`).test(value) ? "" : `PLZ muss aus ${digits} Ziffern bestehen.`;
return isValidPlz(value, digits) ? "" : `PLZ muss aus ${digits} Ziffern bestehen.`;
}
function validatePackstationNumber(value: string): string {
@@ -243,19 +244,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 +279,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 +316,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 +349,9 @@ export function CheckoutContent({
shippingZip,
shippingCity,
shippingCountry,
shippingCompanyName,
shippingContactEmail,
shippingContactPhone,
newsletterOptIn,
shippingMethodId,
paymentMethodId,
@@ -356,6 +377,9 @@ export function CheckoutContent({
shippingZip,
shippingCity,
shippingCountry,
shippingCompanyName,
shippingContactEmail,
shippingContactPhone,
newsletterOptIn,
shippingMethodId,
paymentMethodId,
@@ -372,7 +396,12 @@ 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;
// For VersandModal — same "lowest freeShippingThreshold among active
// methods" rule as CartContent.tsx/getTrustBadges(), independent of
// whichever method is currently selected in the radio list above.
const shippingMethodThresholds = shippingMethods.map((m) => m.freeShippingThreshold).filter((t): t is number => t !== null);
const lowestFreeShippingThreshold = shippingMethodThresholds.length > 0 ? Math.min(...shippingMethodThresholds) : null;
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
const taxBreakdown = computeTaxBreakdown(
items.map(({ entry, product }) => ({
@@ -449,6 +478,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 +623,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 +979,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 +1019,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 +1067,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 +1121,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 +1160,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 +1192,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 +1252,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 +1457,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" />
@@ -1430,7 +1556,13 @@ export function CheckoutContent({
</Reveal>
</form>
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} shipping={shippingSettings} />
<VersandModal
open={versandOpen}
onClose={() => setVersandOpen(false)}
shipping={shippingSettings}
shippingCost={shippingMethods[0]?.price ?? 0}
freeShippingThreshold={lowestFreeShippingThreshold}
/>
</>
);
}
+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>
+51 -40
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { addToCart, useCart } from "../lib/cart";
import { useCartFly } from "./CartFly";
import { NotifyMeForm } from "./NotifyMeForm";
const FEEDBACK_MS = 2000;
@@ -17,6 +18,7 @@ export function AddToCartButton({
label,
className,
productId = "todo-karten",
numericId,
outOfStock = false,
maxQty = null,
variants = [],
@@ -27,6 +29,10 @@ export function AddToCartButton({
* ProductSpotlight passes the actual CMS-selected spotlight product's id
* explicitly, since that can now be a different product. */
productId?: string;
/** Payload's real numeric product id (`product.numericId`) — only used to
* scope a NotifyMeForm signup once out of stock, never for the cart/
* checkout path itself (that stays on the slug `productId` above). */
numericId: number;
/** Product-level — only meaningful when `variants` is empty, same split as
* AddToCartInlineButton. */
outOfStock?: boolean;
@@ -82,7 +88,9 @@ export function AddToCartButton({
: added
? "border border-success! bg-success-subtle! hover:bg-success-subtle! text-success!"
: "";
const displayLabel = currentlyOutOfStock ? "Ausverkauft" : limitReached ? "Maximale Menge im Warenkorb" : label;
// currentlyOutOfStock has no branch here — that state renders
// NotifyMeForm instead of this button entirely (see below).
const displayLabel = limitReached ? "Maximale Menge im Warenkorb" : label;
return (
// Low stock is deliberately NOT surfaced here as its own text line
@@ -111,46 +119,49 @@ export function AddToCartButton({
))}
</select>
)}
<button
ref={buttonRef}
type="button"
onClick={handleClick}
disabled={disabled}
className={`${base} ${stateClasses}`}
>
{/* CSS-grid text-stack, not just swapping the button's text node
directly — this button is inline-flex/content-sized (no w-full),
so "Hinzugefügt ✓" being shorter than most labels made the whole
button visibly shrink while showing the success state. Stacking
both possible texts in the same grid cell (both invisible ones
still contribute to sizing) reserves width for whichever is
wider, so the button's box never changes size either way. Now
also reserves space for "Ausverkauft"/"Maximale Menge im
Warenkorb" — the widest of the four wins regardless of which is
showing. */}
{/* whitespace-nowrap — inherited by every stacked span below. On a
w-full button (e.g. this page's mobile layout), "Maximale Menge
im Warenkorb" is long enough to wrap to two lines without this,
and since every stacked span shares the same grid cell, that
inflated the row height for whichever text is actually showing
too — "Ausverkauft" rendered with a tall empty gap underneath it
(fixed 2026-07-24). */}
<span className="relative grid whitespace-nowrap">
<span className="invisible [grid-area:1/1]" aria-hidden="true">
{label}
{currentlyOutOfStock ? (
// Replaces the button slot entirely rather than stacking below a
// disabled "Ausverkauft" button — same reasoning as
// AddToCartInlineButton's identical swap (see that file's own
// comment on the `items-start` grid fix this relies on).
<NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />
) : (
<button
ref={buttonRef}
type="button"
onClick={handleClick}
disabled={disabled}
className={`${base} ${stateClasses}`}
>
{/* CSS-grid text-stack, not just swapping the button's text node
directly — this button is inline-flex/content-sized (no w-full),
so "Hinzugefügt ✓" being shorter than most labels made the whole
button visibly shrink while showing the success state. Stacking
both possible texts in the same grid cell (both invisible ones
still contribute to sizing) reserves width for whichever is
wider, so the button's box never changes size either way. Now
also reserves space for "Maximale Menge im Warenkorb" — the
widest of the three wins regardless of which is showing. */}
{/* whitespace-nowrap — inherited by every stacked span below. On a
w-full button (e.g. this page's mobile layout), "Maximale Menge
im Warenkorb" is long enough to wrap to two lines without this,
and since every stacked span shares the same grid cell, that
inflated the row height for whichever text is actually showing
too (fixed 2026-07-24). */}
<span className="relative grid whitespace-nowrap">
<span className="invisible [grid-area:1/1]" aria-hidden="true">
{label}
</span>
<span className="invisible [grid-area:1/1]" aria-hidden="true">
Hinzugefügt
</span>
<span className="invisible [grid-area:1/1]" aria-hidden="true">
Maximale Menge im Warenkorb
</span>
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : displayLabel}</span>
</span>
<span className="invisible [grid-area:1/1]" aria-hidden="true">
Hinzugefügt
</span>
<span className="invisible [grid-area:1/1]" aria-hidden="true">
Ausverkauft
</span>
<span className="invisible [grid-area:1/1]" aria-hidden="true">
Maximale Menge im Warenkorb
</span>
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : displayLabel}</span>
</span>
</button>
</button>
)}
</div>
);
}
+35 -16
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { addToCart, useCart } from "../lib/cart";
import { useCartFly } from "./CartFly";
import { NotifyMeForm } from "./NotifyMeForm";
// Exported so consumers like RelatedProducts.tsx can delay their own
// follow-up UI changes (e.g. swapping out this exact card) until after
@@ -18,6 +19,7 @@ export const FEEDBACK_MS = 2000;
*/
export function AddToCartInlineButton({
id,
numericId,
label = "In den Warenkorb",
className,
outOfStock = false,
@@ -25,6 +27,10 @@ export function AddToCartInlineButton({
variants = [],
}: {
id: string;
/** Payload's real numeric product id (`product.numericId`) — only used to
* scope a NotifyMeForm signup once out of stock, never for the cart/
* checkout path itself (that stays on the slug `id` above). */
numericId: number;
label?: string;
className?: string;
/** Product-level — only meaningful when `variants` is empty. A varianted
@@ -107,23 +113,36 @@ export function AddToCartInlineButton({
))}
</select>
)}
<button
ref={buttonRef}
type="button"
onClick={handleClick}
disabled={disabled}
className={`${base} ${stateClasses}`}
>
<span
className={
"text-body-sm transition-colors " +
(disabled ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
}
{currentlyOutOfStock ? (
// Replaces the button slot entirely rather than stacking below a
// disabled "Ausverkauft" button. An out-of-stock card is taller
// than its in-stock siblings now — ProductGrid.tsx/MerklisteGrid.tsx/
// RelatedProducts.tsx all use `items-start` on their grid (not the
// CSS Grid default `stretch`) specifically so that doesn't cascade
// into pushing every other card's button down to match; an earlier
// attempt reserved the extra height invisibly on every card
// instead, which looked worse in practice (visible dead space
// under in-stock cards' buttons).
<NotifyMeForm productId={numericId} variantName={variants.length > 0 ? (selectedVariant ?? "") : ""} />
) : (
<button
ref={buttonRef}
type="button"
onClick={handleClick}
disabled={disabled}
className={`${base} ${stateClasses}`}
>
{currentlyOutOfStock ? "Ausverkauft" : limitReached ? "Maximale Menge im Warenkorb" : added ? "Hinzugefügt ✓" : label}
</span>
<Image alt="" src="/icon-cart-outline.png" width={32} height={30} className="h-[1.875rem] w-8 object-contain" />
</button>
<span
className={
"text-body-sm transition-colors " +
(disabled ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
}
>
{limitReached ? "Maximale Menge im Warenkorb" : added ? "Hinzugefügt ✓" : label}
</span>
<Image alt="" src="/icon-cart-outline.png" width={32} height={30} className="h-[1.875rem] w-8 object-contain" />
</button>
)}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
// Mirror of ArrowRightIcon.tsx — same reasoning applies here: the Unicode
// "←" (U+2190) previously used inline in CartContent.tsx's "Weiter
// einkaufen" links isn't covered by the site's custom web fonts, so it
// fell back to a system font whose vertical metrics sit visibly low on
// mobile relative to the label text next to it. An SVG has no
// font-fallback path — renders identically everywhere.
export function ArrowLeftIcon() {
return (
<svg aria-hidden width="16" height="12" viewBox="0 0 16 12" fill="none" className="shrink-0">
<path d="M15 6H1M1 6L4 3M1 6L4 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+21
View File
@@ -0,0 +1,21 @@
// Replaces the Unicode "→" (U+2192) previously used inline in CTA links
// (Tools.tsx, Blog.tsx, ProductGrid.tsx) — that glyph isn't covered by the
// site's custom web fonts, so browsers fall back to a system font just for
// that one character. The fallback's vertical metrics differ enough by
// platform (confirmed: sat visibly low relative to the label text on a
// Samsung Galaxy S22/Android Chrome, not reproducible in desktop Chromium)
// that centering it via flex `items-center` alone isn't reliable across
// devices. An SVG has no font-fallback path — it renders identically
// everywhere. `currentColor` stroke follows the parent Link's own
// text/hover color, same as every other icon in this codebase.
// Arrowhead wings are deliberately short relative to the shaft (4.2 units
// vs. a 14-unit shaft) — the first version used full-length 45° wings
// (7 units), which read as a generic, oversized chevron next to the small
// bold label text it sits beside.
export function ArrowRightIcon() {
return (
<svg aria-hidden width="16" height="12" viewBox="0 0 16 12" fill="none" className="shrink-0">
<path d="M1 6H15M15 6L12 3M15 6L12 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+22 -17
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import Image from "next/image";
import { getBlogPosts } from "../lib/payload";
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
import { ArrowRightIcon } from "./ArrowRightIcon";
export async function Blog() {
const posts = await getBlogPosts(3);
@@ -31,23 +32,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 +59,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>
@@ -69,34 +70,38 @@ export async function Blog() {
href={featured.href}
className="flex items-center gap-1 font-bold text-body text-text-primary whitespace-nowrap hover:text-brand transition-colors"
>
<span aria-hidden></span>
<ArrowRightIcon />
<span>Zum Beitrag</span>
</Link>
</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 +112,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>
@@ -118,7 +123,7 @@ export async function Blog() {
href={post.href}
className="flex items-center gap-1 font-bold text-body whitespace-nowrap hover:text-brand transition-colors"
>
<span aria-hidden></span>
<ArrowRightIcon />
<span>Zum Beitrag</span>
</Link>
</div>
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
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";
}
// Client-side navigation (not a plain <Link>) specifically so `isPending`
// can gate the chips: rapid clicks used to fire multiple concurrent RSC
// navigations with no guarantee they'd commit in the order they were
// requested, so a slower, stale navigation could land after a newer one and
// briefly show the wrong (sometimes empty) result. Disabling the chips
// while a navigation is in flight makes that ordering race impossible —
// only one navigation can ever be outstanding at a time.
export function BlogCategoryFilter({ allCategories, activeCategories }: { allCategories: string[]; activeCategories: string[] }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
return (
<div className="flex flex-wrap gap-2 w-full">
{allCategories.map((category) => {
const active = activeCategories.includes(category);
return (
<button
key={category}
type="button"
disabled={isPending}
onClick={() => startTransition(() => router.push(buildCategoryHref(activeCategories, category)))}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-body-sm font-semibold whitespace-nowrap border transition-colors disabled:opacity-60 disabled:pointer-events-none ${
active
? "bg-brand border-brand text-text-primary"
: "bg-bg-muted border-border text-text-muted hover:border-brand hover:text-brand"
}`}
>
{category}
</button>
);
})}
</div>
);
}
+15 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { useCart } from "../lib/cart";
import { useCart, pullServerCart } from "../lib/cart";
// Mirrors the local cart to the server whenever it changes, so a logged-in
// customer's cart follows them across devices (see Customers.ts's `cart`
@@ -10,6 +10,14 @@ import { useCart } from "../lib/cart";
// no-op when logged out — POST /api/account/cart 401s in that case, which
// this component doesn't need to distinguish from success; there's simply
// nothing to keep in sync yet.
//
// Push (local→server) and pull (server→local) are both handled here, but
// deliberately asymmetric: push reacts to every local cart change (that's
// this device's news to share), pull only runs on mount and on window
// focus (see pullServerCart()'s own comment on why it's safe to call
// repeatedly) — no polling interval, since "another device changed my
// cart while this tab has been open and unfocused the whole time" is a
// rare enough case not to justify a persistent timer.
export function CartSync() {
const cart = useCart();
const isFirstRender = useRef(true);
@@ -34,5 +42,11 @@ export function CartSync() {
return () => clearTimeout(timeout);
}, [cart]);
useEffect(() => {
pullServerCart();
window.addEventListener("focus", pullServerCart);
return () => window.removeEventListener("focus", pullServerCart);
}, []);
return null;
}
+160
View File
@@ -0,0 +1,160 @@
"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}
// Without this, a mousedown on an <li> blurs the trigger button
// first (li isn't focusable) — the resulting onBlur closes and
// unmounts this list before the click event ever fires, so
// nothing is ever selectable by mouse/touch.
onMouseDown={(e) => e.preventDefault()}
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 */}
+44
View File
@@ -0,0 +1,44 @@
// Server Component — no "use client" needed. The consent gating itself
// happens entirely client-side inside Klaro (KlaroConsentManager.tsx),
// not via any React state here: Klaro's own contextual-consent DOM scan
// (renderContextualConsentNotices, runs as part of Klaro.render()) finds
// this iframe by its `data-name="google-maps"` attribute after mount,
// blanks its `src` and inserts its own "Karte laden?" placeholder in
// front of it until that service's consent is granted, then restores the
// real `src`. See klaroConfig.ts's own comment on the "google-maps"
// service this depends on — a `tracking-codes` row with that provider
// must exist and be `active`, or Klaro never gates (and never un-blanks)
// this iframe at all.
//
// `width`/`height` are real HTML attributes, not just the Tailwind aspect-
// ratio wrapper below — Klaro reads `element.width`/`element.height` (the
// DOM attributes) to size its own placeholder box before the real iframe
// has rendered, so both need to be present even though the wrapper's
// `aspect-[...]` class is what actually controls layout.
export function GoogleMapsEmbed({
src,
title,
className,
}: {
/** A full Google Maps embed URL (Google Maps → Teilen → Karte einbetten
* → "HTML kopieren" → the `src` attribute of that iframe), e.g.
* "https://www.google.com/maps/embed?pb=...". */
src: string;
title: string;
className?: string;
}) {
return (
<div className={`relative w-full aspect-[16/9] overflow-hidden rounded-md border border-border ${className ?? ""}`}>
<iframe
data-name="google-maps"
src={src}
title={title}
width={800}
height={450}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="absolute inset-0 h-full w-full border-0"
/>
</div>
);
}
+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 />
+296
View File
@@ -0,0 +1,296 @@
"use client";
import { useEffect, useState } from "react";
import "klaro/dist/klaro.css";
import type { TrackingCode } from "../lib/payload";
import { buildKlaroConfig } from "../lib/klaroConfig";
import { loadTrackingCode } from "../lib/loadTrackingCode";
// Replaced a hand-rolled CookieBanner.tsx + useConsent.ts + TrackingScripts.tsx
// with kiprotect/klaro (open source, self-hosted, npm install klaro) —
// the custom banner only ever covered the accept/reject UI itself; Klaro
// additionally brings a real per-service consent list, bundled German UI
// translations, and (via `cookies`, not used here yet) cookie-deletion on
// withdrawal — all things a hand-rolled version would have had to build
// from scratch. See project memory for the fuller reasoning.
//
// Dynamically imported inside an effect (client-only, after mount) rather
// than a static top-level import — Klaro touches `window`/`document` at
// module-eval time in places, which isn't SSR-safe. The CSS import above
// stays static (Next.js requires CSS imports to be static, not inside a
// dynamic import()), paired with the "-no-css" JS build so the stylesheet
// isn't loaded twice.
//
// `buildKlaroConfig`'s `styling` CSS-var overrides (brand colors, corner
// position) only get Klaro so far — its bundled klaro.css still draws its
// own border/shadow/spacing/typography, which read as an obviously
// bolted-on library widget next to this site's own hand-designed
// components (NewsletterModal.tsx, NotifyMeForm.tsx, etc.). The <style>
// block below is a real CSS override pass against Klaro's actual DOM
// classnames (confirmed against kiprotect/klaro's own src/scss/*.scss,
// not guessed) — same "plain unlayered <style> tag wins the cascade"
// approach the Payload admin's own AdminUIStyles.tsx uses, `!important`
// added only where needed to beat klaro.css's own rules of otherwise-equal
// specificity. Kills Klaro's default border entirely (replaced with a
// soft shadow, matching this site's own card language) and restyles
// every button to the site's actual rounded/weighted look instead of
// Klaro's generic flat rectangles.
function KlaroTheme() {
return (
<style>{`
/* Blanket reset first — the previous pass only targeted
.cookie-notice/.cookie-modal directly and a hard black border
still showed up in production (screenshot-confirmed), so every
descendant gets border/outline stripped here regardless of which
specific Klaro rule was actually drawing it; the two rules below
re-add exactly the borders this theme actually wants. */
.klaro, .klaro * { box-sizing: border-box; border: 0 !important; outline: 0 !important; }
/* Matches NewsletterModal.tsx's own backdrop color
(bg-[rgba(134,134,134,0.9)]) instead of Klaro's default plain
black at 50% — same dimming purpose, but consistent with how
every other modal on this site already looks. .cm-bg is a
*sibling* of the actual dialog box (.cm-modal.cm-klaro), both
direct children of the full-screen .cookie-modal wrapper — see
that wrapper's own comment below on why it must never be resized/
transformed itself. */
.klaro .cm-bg { background: rgba(134, 134, 134, 0.9) !important; }
.klaro .cookie-notice, .klaro .cm-modal.cm-klaro {
box-shadow: 0 20px 44px -14px rgba(26,26,24,0.22), 0 4px 14px rgba(26,26,24,0.07) !important;
border-radius: 18px !important;
font-family: var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
}
.klaro .cookie-notice .cn-body { padding: 22px 24px !important; }
.klaro .cookie-notice p, .klaro .cm-modal.cm-klaro p {
font-size: 14px !important;
line-height: 1.6 !important;
margin-top: 0 !important;
margin-bottom: 10px !important;
}
.klaro h1, .klaro h2 {
font-family: var(--font-lora), Georgia, serif !important;
font-weight: 700 !important;
letter-spacing: -0.01em !important;
}
.klaro .cm-btn {
border: none !important;
border-radius: 8px !important;
font-weight: 700 !important;
font-size: 13px !important;
padding: 10px 18px !important;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.klaro .cm-btn:hover { opacity: 0.88; }
.klaro .cm-btn:active { transform: scale(0.97); }
.klaro .cm-btn:focus-visible { outline: 2px solid #f6a701 !important; outline-offset: 2px; }
/* Side-by-side by default (accept/decline in the notice, the
modal's own bottom action row), only wrapping to a stacked
layout once they genuinely don't fit — flex-wrap does this
naturally at any width, no fixed breakpoint needed. Klaro's own
.cn-ok/.cn-buttons rely on inline-block flow for the same
result, which is fragile; this is the explicit version. */
.klaro .cn-ok, .klaro .cn-buttons, .klaro .cm-buttons {
display: flex !important;
flex-wrap: wrap !important;
gap: 10px !important;
width: auto !important;
}
.klaro .cn-buttons .cm-btn, .klaro .cm-buttons .cm-btn { width: auto !important; margin: 0 !important; }
/* The "Karte laden?" placeholder ContextualConsentNotice renders in
place of a gated embed (GoogleMapsEmbed.tsx) — fills that embed's
own aspect-ratio box, so this needs to look like a real card
slot, not a bare unstyled div floating inside it. */
.klaro.cm-as-context-notice {
height: 100% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.klaro .context-notice {
width: 100% !important;
height: 100% !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
gap: 4px !important;
padding: 24px !important;
background: #fffdf8 !important;
text-align: center !important;
}
.klaro .context-notice p { text-align: center !important; max-width: 32ch; }
.klaro .context-notice .cm-buttons { display: flex !important; gap: 10px !important; margin-top: 6px !important; }
.klaro a.cm-link, .klaro .cookie-notice a, .klaro .cookie-modal a {
color: #a06b00 !important;
text-decoration: underline !important;
font-weight: 600 !important;
}
.klaro select, .klaro .cm-list-input + label {
border-radius: 6px !important;
}
/* ---- Settings modal service/purpose list — a real pass modeled on
well-known CMPs like Cookiebot (roomier modal, clear row
separators, bigger switches, muted-but-legible descriptions),
not just inherited from the notice's own styling.
DOM structure (confirmed against kiprotect/klaro's own
src/components/consent-modal.jsx — the FIRST version of this
pass guessed wrong and broke the backdrop, see the incident note
below):
.cookie-modal full-viewport wrapper (position:
fixed, 100%×100%) — MUST stay
untouched; giving it its own
transform/position (tried first)
creates a new containing block
for its position:fixed children,
so .cm-bg below started
positioning itself relative to
THIS shrunken/centered box
instead of the real viewport —
the backdrop only covered a
640px-wide column, screenshot-
confirmed 2026-08-02.
.cm-bg the dark backdrop (styled above)
.cm-modal.cm-klaro the actual dialog box — every
size/position override belongs
HERE, not on .cookie-modal.
.cm-header close (×) + h1.title + intro <p>
.cm-body the service/purpose list
.cm-footer > .cm-footer-buttons decline/accept/accept-all
row (NOT .cm-buttons — that
class belongs to the small
notice/context-notice components
only, an earlier pass wrongly
reused it here too and the rule
silently matched nothing). */
.klaro .cm-modal.cm-klaro {
position: fixed !important;
left: 50% !important;
top: 50% !important;
transform: translate(-50%, -50%) !important;
width: calc(100% - 40px) !important;
max-width: 640px !important;
max-height: 88vh !important;
overflow: auto !important;
margin: 0 !important;
}
.klaro .cm-header { padding: 32px 36px 0 !important; }
.klaro .cm-body { padding: 8px 36px !important; }
.klaro .cm-footer { padding: 0 36px 32px !important; }
.klaro .cm-header h1.title { font-size: 26px !important; margin-bottom: 4px !important; }
.klaro .cm-header > p { color: #6b6b69 !important; margin-bottom: 0 !important; }
.klaro .cm-header .hide {
position: absolute !important;
top: 28px !important;
right: 28px !important;
color: #6b6b69 !important;
font-size: 20px !important;
}
.klaro .cm-header .hide:hover { color: #1a1a18 !important; }
/* Re-adds a deliberate row separator the blanket border-reset above
removes — each purpose/service row genuinely benefits from one,
unlike the notice's own outer border which just looked heavy. */
.klaro .cm-switch-container {
border-bottom: 1px solid #e5e0d8 !important;
padding: 16px 4px !important;
padding-left: 70px !important;
}
.klaro .cm-switch-container:last-child { border-bottom: 0 !important; }
.klaro .cm-list-title { font-size: 15px !important; font-weight: 700 !important; }
.klaro .cm-list-description { font-size: 13.5px !important; line-height: 1.5 !important; padding-top: 5px !important; max-width: 46ch; }
.klaro p.purposes { font-size: 12px !important; font-weight: 600 !important; text-transform: uppercase; letter-spacing: 0.04em; margin-top: 6px !important; }
/* Bigger, clearer toggle (44×24 vs. Klaro's cramped 50×30-but-
visually-thin default) — brand amber when on, plain border-tone
off, matching this site's own bg-brand/bg-border pairing. */
.klaro .cm-switch, .klaro .cm-list-input { width: 44px !important; height: 24px !important; }
.klaro .slider { border: 1px solid #e5e0d8 !important; }
.klaro .slider::before { width: 18px !important; height: 18px !important; left: 3px !important; bottom: 2px !important; box-shadow: 0 1px 2px rgba(0,0,0,0.15) !important; }
.klaro .cm-list-input:checked + .cm-list-label .slider::before { transform: translateX(19px) !important; }
.klaro .cm-caret { color: #6b6b69 !important; }
/* Bottom action row (decline/accept/accept-all) — real classname
is .cm-footer-buttons here, NOT .cm-buttons (see the DOM-
structure note above); reads as a distinct footer, not just the
last list item — separated + given the same side-by-side/wrap
treatment as the notice's own buttons. */
.klaro .cm-footer-buttons {
display: flex !important;
flex-wrap: wrap !important;
gap: 10px !important;
border-top: 1px solid #e5e0d8 !important;
margin-top: 16px !important;
padding-top: 20px !important;
}
.klaro .cm-footer-buttons .cm-btn { width: auto !important; margin: 0 !important; }
.klaro .cm-powered-by { display: none !important; }
/* Hides the "alle umschalten" toggle's own boilerplate description
("Mit diesem Schalter können Sie alle Dienste aktivieren oder
deaktivieren.") — redundant next to a plainly-labeled switch.
CSS hide, not an empty translation override — Klaro's own t()
treats an empty string as "translation missing" and renders a
literal "[missing translation: ...]" debug string instead
(confirmed via screenshot 2026-08-02), which is worse than the
original text. */
.klaro .cm-toggle-all .cm-list-description { display: none !important; }
`}</style>
);
}
export function KlaroConsentManager({ codes }: { codes: TrackingCode[] }) {
// Kept in state (not just called once) so the persistent reopen button
// below can call Klaro.show() itself, any time after the initial
// accept/reject decision — a visitor must always be able to revisit
// their choice, not just on first load.
const [klaroModule, setKlaroModule] = useState<typeof import("klaro/dist/klaro-no-css") | null>(null);
useEffect(() => {
// Nothing to ask consent for — don't even load/render Klaro. A cookie
// banner with zero services to list would just be visual noise.
if (codes.length === 0) return;
let cancelled = false;
import("klaro/dist/klaro-no-css").then((Klaro) => {
if (cancelled) return;
const config = buildKlaroConfig(codes, loadTrackingCode);
Klaro.setup(config);
setKlaroModule(Klaro);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- `codes` comes
// from a server-fetched, 60s-ISR-cached layout prop; it's stable for
// the lifetime of this component in practice, and Klaro.setup() isn't
// meant to be called more than once per page load anyway.
}, []);
if (codes.length === 0) return null;
return (
<>
<KlaroTheme />
{klaroModule && (
<button
type="button"
onClick={() => klaroModule.show()}
aria-label="Cookie-Einstellungen öffnen"
title="Cookie-Einstellungen"
className="fixed bottom-5 left-5 z-40 flex h-11 w-11 items-center justify-center rounded-full border border-border bg-bg-base shadow-md hover:border-brand transition-colors"
>
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
<circle cx="9" cy="9.5" r="1.1" fill="currentColor" />
<circle cx="14" cy="8.5" r="1" fill="currentColor" />
<circle cx="15" cy="13.5" r="1.1" fill="currentColor" />
<circle cx="10.5" cy="14.5" r="1" fill="currentColor" />
<circle cx="12" cy="11" r="0.9" fill="currentColor" />
</svg>
</button>
)}
</>
);
}
+165 -21
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, SearchOverlay } 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,71 @@ function AccountLink() {
);
}
// Wishlist icon + count badge — only rendered by the caller when
// `wishlistEnabled` (CompanySettings). Visible at every width, alongside
// Search/Account/Cart — all 4 stay full 44px touch targets even on the
// smallest phones (shrinking them was tried and reverted: it made them
// hard to hit accurately). The logo shrinks further below 375px instead
// to make room — confirmed via an actual Playwright viewport sweep down
// to 320px that this fits without wrapping/overflow (see git history).
function WishlistLink() {
const { count } = useWishlist();
const [pulse, setPulse] = useState(false);
const prevCountRef = useRef(0);
// Same guard as CartLink's own — useWishlist() starts from an empty
// cached/SSR-safe list and only fills in the real count once its own
// fetch resolves client-side, so without this the badge pulsed on every
// page load/reload the instant that first real count arrived, not just
// on an actual add/remove during the session.
const hasMountedRef = useRef(false);
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true;
prevCountRef.current = count;
return;
}
if (count !== prevCountRef.current) {
setPulse(true);
const t = setTimeout(() => setPulse(false), 350);
prevCountRef.current = count;
return () => clearTimeout(t);
}
}, [count]);
return (
<Link
href="/konto/merkliste"
aria-label={count > 0 ? `Merkliste, ${count} Artikel` : "Merkliste"}
className="relative 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 transition-transform duration-300 " + (pulse ? "scale-110" : "scale-100")}
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 transition-transform duration-300 " +
(pulse ? "scale-125" : "scale-100")
}
>
{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
@@ -142,6 +209,12 @@ function CartLink() {
const anchorRef = useRef<HTMLAnchorElement>(null);
const [pulse, setPulse] = useState(false);
const prevVisibleRef = useRef(0);
// useCartCount's getServerSnapshot is always 0 (see cart.ts) so hydration
// always transitions 0 -> the real count on first render — without this
// guard that transition alone satisfied "visibleCount > prevVisibleRef"
// and pulsed the badge on every single page load/reload, not just an
// actual in-session cart change.
const hasMountedRef = useRef(false);
const pathname = usePathname();
// Held back by pendingCount while a ball is mid-flight, so the badge only
@@ -156,6 +229,11 @@ function CartLink() {
}, [registerCartIcon]);
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true;
prevVisibleRef.current = visibleCount;
return;
}
if (visibleCount > prevVisibleRef.current) {
setPulse(true);
const t = setTimeout(() => setPulse(false), 350);
@@ -210,12 +288,21 @@ 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("");
const [mobileOpen, setMobileOpen] = useState(false);
const [newsletterOpen, setNewsletterOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const hamburgerRef = useRef<HTMLButtonElement>(null);
@@ -383,13 +470,23 @@ 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"
: "bg-bg-base"
}`}
>
<div className="flex h-[6.25rem] w-full shrink-0 items-center px-8">
<div className="flex h-[6.25rem] w-full shrink-0 items-center px-2 min-[375px]:px-4 sm:px-8">
<div className="w-full flex items-center justify-between">
{/* Logo — real navigation to "/" from anywhere else; only when
@@ -401,7 +498,7 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
while leaving that page's content on screen. */}
<Link
href="/"
className="shrink-0"
className="shrink-0 w-[76px] min-[375px]:w-[130px] sm:w-[181px]"
onClick={
pathname === "/"
? (e) => {
@@ -413,11 +510,21 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
: closeMobile
}
>
{/* Fixed width/height are the real file dimensions (Next Image
needs them for optimization/layout); the wrapping Link's own
w-[112px] min-[375px]:w-[130px] sm:w-[181px] + h-auto here is
what actually shrinks the rendered logo below 640px — there
wasn't enough header width for 4 full-44px icons + hamburger
otherwise (confirmed via an actual Playwright viewport sweep
down to 320px, not assumed) — the icons themselves are never
shrunk (see WishlistLink's own comment on why), so the logo
is what gives on the very smallest phones instead. */}
<Image
src="/logo.png"
alt="einfach produktiv"
width={181}
height={61}
className="w-full h-auto"
priority
/>
</Link>
@@ -428,8 +535,27 @@ 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. */}
<nav className="hidden lg:flex items-center gap-12">
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.
The gap itself, though, does scale fluidly — just on its own
1024-1300px range (this nav's own floor/ceiling, only ever
relevant while it's actually visible), not the site-wide
640-1440px scale: clamp(1.5rem, 8.696vw - 4.065rem, 3rem) is
gap-6 (1.5rem) at exactly 1024px, gap-12 (3rem) from 1300px
up, and eases linearly between — the fixed 48px gap read as
too wide right where the nav labels themselves have the
least room (just above 1024px). */}
<nav className="hidden lg:flex items-center gap-[clamp(1.5rem,8.696vw_-_4.065rem,3rem)]">
{navLinks.map((link) => {
const isActive = isNavLinkActive(link.href, pathname, activeSection);
const underline = (
@@ -479,17 +605,24 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
{/* Trailing controls — CTA buttons (md+), cart (always), hamburger
(below lg). Grouped so spacing stays consistent as individual
children hide/show across the three breakpoint tiers. */}
<div className="flex items-center gap-2">
{/* No gap between these two — each is already a 44px touch
target with the icon centered inside, so even gap-0 here
still leaves ~20px of visual space between the actual
glyphs. The outer gap-2 is what separates this pair from
the CTA-buttons/hamburger group that follows, and stays
untouched. Fixed 2026-07-24: gap-2 here on top of that
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 gap-1 min-[375px]:gap-2">
{/* No gap between these — each is a full 44px touch target
(never shrunk, even on the smallest phones — a smaller
target was tried and reverted for being hard to tap
accurately) with the icon centered inside, so even gap-0
here still leaves visual space between the actual glyphs.
All 4
icons (Search/Account/Wishlist/Cart) are visible at every
width, including true mobile — the 375px-and-below size
step exists specifically so all 4 plus the hamburger fit
without wrapping/overflow on the narrowest real phone
viewports (checked at 320px). The outer gap-2 is what
separates this group from the CTA-buttons/hamburger group
that follows. */}
<div className="flex items-center">
{searchEnabled && <SearchButton onOpen={() => setSearchOpen(true)} />}
<AccountLink />
{wishlistEnabled && <WishlistLink />}
<CartLink />
</div>
@@ -509,10 +642,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 +658,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 +707,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 +801,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>
@@ -671,6 +814,7 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
</AnimatePresence>
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
{searchEnabled && <SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} />}
</>
);
}
+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">
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { isValidEmail } from "../lib/email";
import { ArrowRightIcon } from "./ArrowRightIcon";
/**
* Replaces the (disabled) Add-to-cart button's spot once a product/variant
* is out of stock — lets a visitor leave their email to be notified once
* lib/jobs/sendBackInStockEmails.ts (Payload backend) sends the "it's
* back" mail. Always shows the email input + submit control directly (no
* extra click to reveal them).
*
* One input with the submit control embedded inside it (absolutely
* positioned, not a layout sibling) — not a separate stacked button below
* the input. A first version stacked input + full-width "Benachrichtigen"
* button, which made an out-of-stock ProductCard.tsx visibly taller than
* its in-stock siblings (two form rows vs. one button); shrinking that
* stacked version's own padding was tried and reverted — the actual ask
* was to keep every control's height untouched and make the CARD match
* instead. Embedding the submit icon inside the input keeps this whole
* control to exactly one row, given an explicit h-14 (3.5rem/56px) to
* match AddToCartInlineButton's own "In den Warenkorb" button's actual
* rendered height — that button isn't 56px from its py-3 padding alone,
* its 1.875rem cart-icon image is the tallest thing in it, so matching
* padding here wouldn't have matched height; a sold-out card now ends up
* exactly as tall as an in-stock one.
*/
export function NotifyMeForm({ productId, variantName = "" }: { productId: number; variantName?: string }) {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!isValidEmail(email)) {
setError("Bitte gib eine gültige E-Mail-Adresse ein.");
return;
}
setError("");
setStatus("submitting");
try {
const res = await fetch("/api/stock-notifications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, productId, variantName }),
});
const data: { ok: boolean; reason?: string } = await res.json();
if (!data.ok) {
setStatus("error");
setError(data.reason || "Eintragen hat nicht geklappt. Bitte versuch es später erneut.");
return;
}
setStatus("success");
} catch {
setStatus("error");
setError("Eintragen hat nicht geklappt. Bitte versuch es später erneut.");
}
}
if (status === "success") {
return <p className="text-body-sm text-success">Danke! Wir melden uns, sobald es wieder verfügbar ist.</p>;
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-1.5 w-full">
<div className="relative w-full">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Bei Verfügbarkeit benachrichtigen"
aria-label="E-Mail-Adresse für Benachrichtigung, sobald das Produkt wieder verfügbar ist"
// h-14 (3.5rem/56px), not py-3 alone — AddToCartInlineButton's
// own button isn't 56px tall because of its py-3 padding alone,
// it's that plus its 1.875rem/30px cart-icon image, which is
// taller than this input's own text line-height would be at
// that same padding. Setting the height explicitly (rather than
// trying to reverse-engineer a padding value that happens to
// produce 56px for text-body-sm) is what actually matches it.
className="w-full h-14 rounded-sm border border-border pl-3 pr-12 text-body-sm text-text-primary bg-bg-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
/>
<button
type="submit"
disabled={status === "submitting"}
aria-label="Benachrichtigen"
title="Benachrichtigen"
className="absolute right-1.5 top-1/2 -translate-y-1/2 flex size-8 items-center justify-center rounded-sm text-text-primary hover:text-brand transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{status === "submitting" ? "…" : <ArrowRightIcon />}
</button>
</div>
{error && <p className="text-label text-red-600">{error}</p>}
</form>
);
}
+153
View File
@@ -0,0 +1,153 @@
import Link from "next/link";
import Image from "next/image";
import type { ReactNode } from "react";
import { formatPrice, discountPercent } from "../lib/format";
import { effectiveTaxRate } from "../lib/cartTotals";
import { AddToCartInlineButton } from "./AddToCartInlineButton";
import { WishlistButton } from "./WishlistButton";
import type { Product } from "../lib/payload";
// Single shared card markup for every product grid (ProductGrid,
// RelatedProducts, MerklisteGrid) — these three used to each duplicate
// this JSX independently and had drifted (different image aspect ratios,
// a "Mehr erfahren" link present on some but not others, purchased/
// out-of-stock badges only on some). The title itself is now the card's
// only link (product.href, when set) — no separate "Mehr erfahren" CTA
// line, which is also what makes the card more compact than before.
// No "use client" — plain enough (no hooks/browser APIs of its own) to
// render from both ProductGrid's Server Component and RelatedProducts'/
// MerklisteGrid's Client Components.
export function ProductCard({
product,
defaultTaxRate,
kleinunternehmer,
wishlistEnabled,
wishlistRevealOnHover = false,
topLeftBadge,
belowPrice,
className = "",
}: {
product: Product;
defaultTaxRate: number;
kleinunternehmer: boolean;
wishlistEnabled: boolean;
/** See WishlistButton's own doc: true for any grid where an unprompted
* heart on every card would read as noise (ProductGrid, RelatedProducts);
* false (default) for /konto/merkliste, where every card is already
* wishlisted. */
wishlistRevealOnHover?: boolean;
/** Overrides the default Ausverkauft/discount pill — used by
* MerklisteGrid for its "Gekauft am ..." badge. Pass `null` to render
* no badge at all. */
topLeftBadge?: ReactNode;
/** Rendered directly under the price — e.g. ProductGrid's delivery-time
* line. Omitted entirely by grids that don't have anything to say there
* (RelatedProducts, MerklisteGrid), rather than every card carrying a
* fixed slot for content only one of the three actually has. */
belowPrice?: ReactNode;
className?: string;
}) {
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;
const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
return (
<div
className={`group bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full transition-transform duration-300 hover:-translate-y-1 ${className}`}
>
<div className="relative w-full aspect-[276/210] overflow-hidden">
{/* Link wraps only the image, not the whole header — WishlistButton
below is its own interactive element and sits as a sibling, not
nested inside this Link (nested interactive elements are both
invalid HTML and would fire navigation on a wishlist click). */}
{product.href ? (
<Link href={product.href} aria-label={product.name} className="absolute inset-0 z-0">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
</Link>
) : (
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
)}
{topLeftBadge !== undefined ? (
topLeftBadge
) : 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>
) : (
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
{wishlistEnabled && (
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover={wishlistRevealOnHover} />
)}
</div>
<div className="flex flex-col gap-3 items-start px-5 pb-5 pt-4 w-full flex-1">
<div className="flex flex-col gap-1 items-start w-full">
{product.categories.length > 0 && (
<p className="text-label font-semibold text-text-muted uppercase tracking-wide">{product.categories.join(", ")}</p>
)}
{product.href ? (
<Link
href={product.href}
className="font-semibold text-h4 text-text-primary w-full hover:text-brand transition-colors"
style={{ fontFamily: "var(--font-lora)" }}
>
{product.name}
</Link>
) : (
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
{product.name}
</p>
)}
</div>
<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>
{belowPrice}
{/* Always rendered, text conditional — reserved height keeps every
card in a row equal height regardless of low-stock status. An
earlier version omitted this for fully-out-of-stock products
(this line can never apply there), on the theory that it was
dead space — but NotifyMeForm's single-row redesign (see its
own comment) now matches AddToCartInlineButton's button height
exactly, which made THIS line the only remaining source of a
mismatch: omitting it made the sold-out card shorter than its
siblings instead of taller. Keeping it unconditional is what
actually gets an exact match now. */}
<p className="min-h-[1.05rem] text-label font-bold text-warning">{anyLowStock ? "Nur noch wenige verfügbar" : null}</p>
{/* flex-1 spacer — pins every card's button to the same Y
regardless of whether the title/category line wraps. */}
<div className="flex-1" />
<AddToCartInlineButton
id={product.id}
numericId={product.numericId}
outOfStock={product.outOfStock}
maxQty={product.maxQty}
variants={product.variants}
/>
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useRef, useState } from "react";
import Image from "next/image";
const SWIPE_THRESHOLD_PX = 40;
/**
* Main image + thumbnail strip, swappable on click. `image` is always
* slide zero, `gallery` (Products.gallery, optional/empty for most
* products) fills in the rest. Renders as a single plain image with no
* thumbnail row at all when `gallery` is empty — the common case, and
* exactly today's pre-gallery appearance, no behavior change for any
* product that hasn't opted in. Plain divs/buttons, no carousel package —
* same "no charting/UI-library dependency for a simple case" reasoning as
* OrderQueueWidget.tsx's own OrderSparkline.
*/
export function ProductGallery({ image, gallery, alt }: { image: string; gallery: string[]; alt: string }) {
const slides = [image, ...gallery];
const [current, setCurrent] = useState(0);
const touchStartX = useRef<number | null>(null);
if (slides.length <= 1) {
return (
<div className="relative w-full aspect-[4/3.1] overflow-hidden rounded-md border border-border bg-bg-base">
<Image src={image} alt={alt} fill sizes="(min-width: 860px) 55vw, 100vw" className="object-cover" />
</div>
);
}
function next() {
setCurrent((c) => (c + 1) % slides.length);
}
function prev() {
setCurrent((c) => (c - 1 + slides.length) % slides.length);
}
function handleTouchStart(e: React.TouchEvent) {
touchStartX.current = e.touches[0].clientX;
}
function handleTouchEnd(e: React.TouchEvent) {
if (touchStartX.current === null) return;
const delta = e.changedTouches[0].clientX - touchStartX.current;
touchStartX.current = null;
if (delta <= -SWIPE_THRESHOLD_PX) next();
else if (delta >= SWIPE_THRESHOLD_PX) prev();
}
return (
<div className="flex flex-col gap-3.5 w-full">
<div
className="relative w-full aspect-[4/3.1] overflow-hidden rounded-md border border-border bg-bg-base touch-pan-y"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<Image src={slides[current]} alt={alt} fill sizes="(min-width: 860px) 55vw, 100vw" className="object-cover" />
<button
type="button"
onClick={prev}
aria-label="Vorheriges Bild"
className="absolute left-3.5 top-1/2 -translate-y-1/2 flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 text-text-primary shadow-md hover:bg-bg-base transition-colors"
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path d="M15 19l-7-7 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
onClick={next}
aria-label="Nächstes Bild"
className="absolute right-3.5 top-1/2 -translate-y-1/2 flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 text-text-primary shadow-md hover:bg-bg-base transition-colors"
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path d="M9 5l7 7-7 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<span className="absolute bottom-3.5 right-3.5 rounded-full bg-text-primary/60 px-2.5 py-1 text-label font-semibold text-white tabular-nums">
{current + 1} / {slides.length}
</span>
</div>
<div className="flex gap-2.5">
{slides.map((src, i) => (
<button
key={src + i}
type="button"
onClick={() => setCurrent(i)}
aria-label={`Bild ${i + 1} anzeigen`}
aria-current={i === current}
className={`relative h-[4.75rem] w-[4.75rem] shrink-0 overflow-hidden rounded-sm border-2 transition-opacity ${
i === current ? "border-brand opacity-100" : "border-transparent opacity-70 hover:opacity-100"
}`}
>
<Image src={src} alt="" fill sizes="76px" className="object-cover" />
</button>
))}
</div>
</div>
);
}
+30 -9
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" revealOnHover />
)}
</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
@@ -106,7 +127,7 @@ export async function ProductSpotlight() {
(matches Tools/Blog above/below), same as AddToCartButton's
own default styling/ring-offset, so no override is needed
here. */}
<AddToCartButton label="In den Warenkorb" productId={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
<AddToCartButton label="In den Warenkorb" productId={product.id} numericId={product.numericId} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
{product.href && (
<Link
href={product.href}
+10
View File
@@ -160,6 +160,16 @@ function buildConverters(quoteLabel: string): JSXConvertersFunction {
{nodesToJSX({ nodes: node.children })}
</a>
),
// Lexical auto-detects a typed-out URL/email as its own "autolink" node,
// distinct from an editor-inserted "link" node — falls through to
// Payload's unstyled default converter without this, which is why the
// Impressum/AGB's typed-in-place mailto addresses rendered as plain
// black text instead of matching every editor-inserted link.
autolink: ({ node, nodesToJSX }) => (
<a href={node.fields?.url ?? "#"} className="text-brand hover:underline">
{nodesToJSX({ nodes: node.children })}
</a>
),
// Lexical's native blockquote feature — used by every post written
// before Blocks existed. Kept working exactly as before (own comment on
// Posts.ts's `content` field editor config on why this stays enabled
+153
View File
@@ -0,0 +1,153 @@
"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;
// Plain trigger button — no state of its own. `open`/`onOpen` are lifted to
// Navbar (mirrors NewsletterModal's pattern) so SearchOverlay itself can be
// rendered as a <header> *sibling* instead of a descendant. Rendering it
// inside <header> put it under the header's conditional `backdrop-blur-md`
// (applied once `scrolled` or `mobileOpen` is true), and per spec a
// `backdrop-filter` makes its element a new containing block for
// `position: fixed` descendants — the overlay's `fixed inset-0` then
// resolved against the ~100px header instead of the viewport, clipping its
// opaque background to that band while the input/results overflowed past
// it, letting the page content underneath show through.
export function SearchButton({ onOpen }: { onOpen: () => void }) {
return (
<button
type="button"
onClick={onOpen}
aria-label="Suche öffnen"
className="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>
);
}
export function SearchOverlay({ open, onClose }: { open: boolean; 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(() => {
if (!open) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKeyDown);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = "";
};
}, [open, onClose]);
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
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");
if (!open) return null;
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"
/>
{/* X icon, not the old "Esc" text — the keyboard shortcut still
works (see the Escape keydown handler above), this button is
just the mouse/touch affordance, and a close icon reads
faster than a text label at a glance. */}
<button type="button" onClick={onClose} aria-label="Suche schließen" className="shrink-0 h-8 w-8 flex items-center justify-center text-text-muted hover:text-brand transition-colors">
<svg viewBox="0 0 16 16" className="h-4 w-4" fill="none" aria-hidden="true">
<path d="M2 2L14 14M14 2L2 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</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
+30 -20
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "./Reveal";
import { ArrowRightIcon } from "./ArrowRightIcon";
import { getWerkzeugeCards } from "../lib/payload";
// Content now lives in Payload (WerkzeugeCards collection). Icons use a
@@ -20,33 +21,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 gap-y-14 sm:grid-cols-12 sm:gap-y-10 gap-x-10 sm:gap-x-[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 +76,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)" }}
@@ -75,17 +88,14 @@ export async function Tools() {
{tool.description}
</p>
</div>
{/* flex items-center + arrow as its own span, not inline text
— the → glyph sits low relative to the surrounding text's
cap-height in the font used here, off-center against the
label if it's just part of the same text node (fixed
2026-07-24, same pattern ProductGrid.tsx's "Mehr
erfahren" link already uses). */}
{/* SVG arrow, not a Unicode "→" character — see
ArrowRightIcon.tsx's own comment on why (font-fallback
vertical-metrics mismatch, platform-dependent). */}
<Link
href={tool.ctaHref}
className="flex items-center gap-1 font-bold leading-normal text-body whitespace-nowrap hover:text-brand transition-colors"
>
<span aria-hidden></span>
<ArrowRightIcon />
<span>{tool.ctaLabel}</span>
</Link>
</div>
+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>
);
}
+5 -1
View File
@@ -17,10 +17,14 @@ export function VersandModal({
open,
onClose,
shipping,
shippingCost,
freeShippingThreshold,
}: {
open: boolean;
onClose: () => void;
shipping: ShippingSettings;
shippingCost: number;
freeShippingThreshold: number | null;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
@@ -109,7 +113,7 @@ export function VersandModal({
</div>
<div className="px-8 py-6 pb-8">
<VersandSections shipping={shipping} />
<VersandSections shipping={shipping} shippingCost={shippingCost} freeShippingThreshold={freeShippingThreshold} />
</div>
</motion.div>
</motion.div>
+100
View File
@@ -0,0 +1,100 @@
"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 /konto/merkliste (every
* card there is already-wishlisted, so hover-reveal would be pointless)
* and the product detail page. 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 any grid or
* card-like feature (ProductGrid.tsx, ProductSpotlight.tsx) where a
* heart sitting there unprompted reads as visual noise. Relies on the
* parent card already carrying `group`/`focus-within` (see
* ProductGrid.tsx/ProductSpotlight.tsx).
*
* On coarse-pointer (touch) devices, hover-reveal can't work at all —
* touch has no persistent `:hover`, so a tap on the card never reliably
* reveals a `group-hover`-gated element the way a mouse hover does.
* Rather than falling back to "always visible at full size" (which
* recreates the exact "a heart on every card" visual noise this mode
* exists to avoid), touch devices get a smaller variant with a lighter
* (not removed — fully transparent made it invisible against some
* product photos) background instead — present and tappable everywhere,
* but visually quieter than the full-size default. Applies to
* already-wishlisted hearts too (not just the hover-revealed ones) —
* consistent sizing across every heart on a touch device, rather than
* only the not-yet-wishlisted ones shrinking. This smaller touch sizing
* is applied regardless of `revealOnHover` (see the className below) —
* a heart reading visually "quieter" on a touch screen is a general
* touch-device trait, not specific to the multi-card-grid use case it
* was first built for. */
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 pointer-coarse:h-7 pointer-coarse:w-7 pointer-coarse:bg-bg-base/60 ${
revealOnHover && !wishlisted
? "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100"
: ""
} ${className}`}
>
<svg
width="20"
height="18"
viewBox="0 0 20 18"
fill={wishlisted ? "currentColor" : "none"}
className={`${wishlisted ? "text-brand" : "text-text-primary"} pointer-coarse:scale-75`}
>
<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>
);
}
@@ -0,0 +1,54 @@
import { headingId } from "../../components/RichText";
import type { CompanySettings } from "../../lib/payload";
import type { TOCSection } from "../../components/SectionTOC";
// Renders "1. Verantwortlicher" straight from company-settings, same
// single-source-of-truth pattern as Impressum's AnbieterAngaben.tsx — this
// used to be hand-typed name/address/email baked into the Datenschutz
// richText (seed-datenschutz.ts on the Payload side), silently out of
// date the moment company-settings changed without a matching manual
// edit here too. The heading itself stays numbered "1." (headingId's own
// `^(\d+)\.` match turns that into "section-1", exactly the id this
// section's TOC entry already had before the RichText stopped rendering
// it — no anchor breakage).
export function verantwortlicherHeadings(): TOCSection[] {
return [{ id: headingId("1. Verantwortlicher"), title: "1. Verantwortlicher" }];
}
function Heading({ children }: { children: string }) {
return (
<h2
id={headingId(children)}
className="font-semibold text-h-small text-text-primary mt-2 scroll-mt-32 first:mt-0"
style={{ fontFamily: "var(--font-lora)" }}
>
{children}
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
</h2>
);
}
function P({ children }: { children: React.ReactNode }) {
return <p className="text-body text-text-body">{children}</p>;
}
export function VerantwortlicherBlock({ seller }: { seller: CompanySettings }) {
return (
<div className="flex flex-col gap-4 w-full">
<Heading>1. Verantwortlicher</Heading>
<P>Verantwortlich für die Datenverarbeitung auf dieser Website ist:</P>
<div className="flex flex-col gap-1">
<P>{seller.managingDirector || seller.sellerName}</P>
<P>{seller.sellerStreet}</P>
<P>
{seller.sellerZip} {seller.sellerCity}
</P>
<P>{seller.sellerCountry}</P>
<P>
E-Mail: <a href={`mailto:${seller.sellerEmail}`} className="text-brand hover:underline">{seller.sellerEmail}</a>
</P>
</div>
<P>Ein gesetzlich vorgeschriebener Datenschutzbeauftragter ist für unser Unternehmen aufgrund seiner Größe derzeit nicht erforderlich.</P>
</div>
);
}
+14 -5
View File
@@ -7,7 +7,9 @@ import { Footer } from "../components/Footer";
import { RichText, extractHeadings } from "../components/RichText";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC, MobileSectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
import { getLegalPage, getCompanySettings } from "../lib/payload";
import { formatMonthYear } from "../lib/format";
import { VerantwortlicherBlock, verantwortlicherHeadings } from "./components/VerantwortlicherBlock";
export const metadata: Metadata = {
title: "Datenschutzerklärung",
@@ -17,8 +19,11 @@ export const metadata: Metadata = {
export default async function DatenschutzPage() {
const { isEnabled: isPreview } = await draftMode();
const page = await getLegalPage("datenschutz", { draft: isPreview });
const headings = page ? extractHeadings(page.content) : [];
const [page, seller] = await Promise.all([getLegalPage("datenschutz", { draft: isPreview }), getCompanySettings()]);
// "1. Verantwortlicher" headings first — that block renders above the
// CMS content below (same reasoning as Impressum's own headings
// composition, see AnbieterAngaben.tsx).
const headings = [...verantwortlicherHeadings(), ...(page ? extractHeadings(page.content) : [])];
return (
<>
@@ -35,7 +40,7 @@ export default async function DatenschutzPage() {
>
Datenschutzerklärung
</p>
<p className="text-body text-text-muted">Stand: Juli 2026</p>
{page && <p className="text-body text-text-muted">Stand: {formatMonthYear(page.updatedAt)}</p>}
</Reveal>
{/* MobileSectionTOC — below lg: only, see SectionTOC.tsx's own
@@ -68,7 +73,11 @@ export default async function DatenschutzPage() {
</div>
</div>
<div className="w-full lg:flex-1 min-w-0">
<div className="w-full lg:flex-1 min-w-0 flex flex-col gap-8">
{/* Name/Adresse/E-Mail kommen direkt aus company-settings, nicht
aus der CMS-Richtext unten — single-sourced, gleiche
Begründung wie Impressum's AnbieterAngaben.tsx. */}
{seller && <VerantwortlicherBlock seller={seller} />}
{page ? (
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
) : (
+75
View File
@@ -0,0 +1,75 @@
import Image from "next/image";
import { Reveal } from "../../components/Reveal";
import { getProductBySlug } from "../../lib/payload";
// Only visually-verifiable claims (Metallkorpus/Chromringe from the
// product photos) — no engraving/color claim, both still unconfirmed
// (see plan notes: Farbvariante/Gravur-Merkmal open as of 2026-08-24).
const bullets = [
"Korpus aus Metall, kein Plastik",
"Liegt gut in der Hand",
"Passt in jede Tasche",
];
// Same fix/reasoning as Hero.tsx's own IconCheck.
function IconCheck() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="size-5 shrink-0 mt-1">
<path d="M4 10.5l4.5 4.5L16 5.5" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
// Unlike todo-cards/components/Focus.tsx, this product has no dedicated
// lifestyle photo yet — only 3 supplier catalog macro shots exist, all
// 338×338px (no larger source, confirmed against the Payload media API).
// Rather than object-cover-stretching a small square into a wide rectangle
// (blurs badly, and is exactly what Pricing.tsx's photo slot already does —
// see plan notes on the "einheitlich" feedback), this uses a contained,
// padded square tile so the source resolution reads as a deliberate macro
// crop instead of a blown-up thumbnail. Uses gallery[1] (the chrome-rings
// close-up) — deliberately not gallery[0], which shows an assortment
// including a black pen and would imply a color variant that isn't
// confirmed yet (see Focus's bullets comment).
export async function Focus() {
const product = await getProductBySlug("stift-kugelschreiber");
if (!product) return null;
const photo = product.gallery[1] ?? product.image;
return (
<section className="w-full bg-bg-base flex flex-col lg:flex-row gap-10 lg:gap-16 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
{/* Fixed max-width, not a viewport-percentage width — same reasoning
as Hero.tsx's gallery cap: the source is only 338×338px, and a
percentage width grows past that on wide screens, exaggerating
the upscale blur. */}
<Reveal className="group relative w-full max-w-[20rem] lg:shrink-0 aspect-square rounded-md overflow-hidden bg-bg-muted p-10">
<Image
src={photo}
alt={product.name}
fill
sizes="320px"
className="object-contain transition-transform duration-500 group-hover:scale-105"
/>
</Reveal>
<Reveal className="flex flex-col gap-6 items-start flex-1 min-w-0 w-full" delay={0.1}>
<p
className="font-semibold text-h-emphasis text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Ein Stift, der bleibt
</p>
<p className="text-body text-text-body">
Die meisten Kugelschreiber landen irgendwann in einer Schublade und werden nicht wieder rausgeholt. Der Alltagsstift soll man behalten.
</p>
<ul className="flex flex-col gap-3 items-start w-full">
{bullets.map((b) => (
<li key={b} className="flex gap-[0.625rem] items-start w-full">
<IconCheck />
<span className="flex-1 font-semibold text-body text-text-primary">{b}</span>
</li>
))}
</ul>
</Reveal>
</section>
);
}
+139
View File
@@ -0,0 +1,139 @@
import Link from "next/link";
import { AddToCartButton } from "../../components/AddToCartButton";
import { ProductGallery } from "../../components/ProductGallery";
import { Reveal } from "../../components/Reveal";
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { formatPrice, discountPercent } from "../../lib/format";
import { effectiveTaxRate } from "../../lib/cartTotals";
const checklist = [
"Funktioniert ohne Strom oder Internet",
"Aus Metall, kein Einwegstift",
"Passt in jede Tasche",
];
// Same fix/reasoning as TodoKartenHero.tsx's IconCheck — icon-check.svg's
// fill can't be recolored from outside the SVG when loaded via <img
// src>/next/image, so this stays an inline stroke path per page.
function IconCheck() {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="size-5 shrink-0 mt-1">
<path d="M4 10.5l4.5 4.5L16 5.5" stroke="#f6a701" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
// Same "compact early teaser + delivery-time repeated next to every buy
// button" reasoning as TodoKartenHero.tsx.
export async function Hero() {
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getProductBySlug("stift-kugelschreiber"),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
const discount = product ? discountPercent(product.price, product.compareAtPrice) : null;
const taxRate = product ? effectiveTaxRate(product, defaultTaxRate) : null;
const anyLowStock = product
? product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock)
: false;
return (
<section className="bg-bg-base w-full overflow-hidden">
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12">
<Reveal className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-6 items-start pl-[var(--layout-padding-x)] pr-10 lg:pr-0">
<p className="flex items-center gap-2 text-body-sm text-text-muted">
<Link href="/" className="hover:text-brand transition-colors">
Startseite
</Link>
<span></span>
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
Werkzeuge
</Link>
<span></span>
<span className="text-text-primary">Der Alltagsstift</span>
</p>
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
<div className="flex flex-col gap-2 items-start w-full">
<p
className="font-semibold text-h-page text-text-primary"
style={{ fontFamily: "var(--font-playfair)" }}
>
Der Alltagsstift<span className="text-brand">.</span>
</p>
<p
className="font-semibold text-h3 text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Ein Stift, der einfach da ist, wenn du ihn brauchst.
</p>
</div>
<p className="text-body text-text-body">
Der Kugelschreiber für die eine Sache, die heute zählt. Schlicht, hochwertig und immer griffbereit.
</p>
<ul className="flex flex-col gap-3 items-start w-full">
{checklist.map((item) => (
<li key={item} className="flex gap-[0.625rem] items-start w-full">
<IconCheck />
<span className="flex-1 text-body text-text-primary">{item}</span>
</li>
))}
</ul>
<div className="flex flex-col gap-1 items-start">
{product && (
<div className="flex gap-2 items-baseline">
{discount !== null && (
<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
? product.noShippingCost
? "Keine Versandkosten"
: "zzgl. Versand"
: `inkl. ${taxRate}% MwSt. ${product.noShippingCost ? " keine Versandkosten" : "zzgl. Versand"}`}
</p>
</div>
)}
{!product?.noShippingCost && (
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
)}
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
</div>
{product && (
<AddToCartButton
label="Alltagsstift bestellen"
numericId={product.numericId}
outOfStock={!product.active || product.outOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
/>
)}
</div>
</Reveal>
{/* max-w cap, not the full lg:col-span-7 width — the source photos
are only 338×338px (checked directly against the Payload media
API, no larger size exists), so letting ProductGallery stretch
to the full column blows them up hard. Capping the display
width keeps the upscale factor small enough that it doesn't
look broken; it can't fix the underlying resolution, only hide
it. Real fix is new source photos, not a layout tweak. */}
{product && product.gallery.length > 0 && (
<Reveal className="order-2 lg:order-none lg:col-span-7 flex lg:items-center" delay={0.15}>
<div className="w-full max-w-[28rem] mx-auto lg:mx-0">
<ProductGallery image={product.image} gallery={product.gallery} alt={product.name} />
</div>
</Reveal>
)}
</div>
</section>
);
}
@@ -0,0 +1,72 @@
import { Fragment } from "react";
import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
import { StepArrow } from "../../components/StepArrow";
// Same 3 generic step icons as todo-cards/components/HowItWorks.tsx — their
// artwork (pen tip, checklist, arrow) reads generically enough to carry a
// different 3-step flow without looking mismatched.
const steps = [
{
icon: "/icon-step-1.png",
width: 165,
height: 177,
title: "1. Griffbereit halten",
desc: "Der Alltagsstift liegt dort, wo du ihn brauchst Tasche, Schreibtisch, Notizbuch.",
},
{
icon: "/icon-step-2.png",
width: 180,
height: 168,
title: "2. Sofort notieren",
desc: "Du musst nicht erst dein Handy entsperren oder eine App öffnen.",
},
{
icon: "/icon-step-3.png",
width: 180,
height: 177,
title: "3. Weitermachen",
desc: "Notiert ist notiert, du machst einfach da weiter, wo du warst.",
},
];
export function HowItWorks() {
return (
<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"
style={{ fontFamily: "var(--font-lora)" }}
>
So einfach ist er im Alltag
</p>
<p className="text-body text-text-muted">
Du nimmst ihn, schreibst, steckst ihn wieder ein. Mehr braucht es nicht.
</p>
</Reveal>
<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 sm:max-w-none">
<Image
src={step.icon}
alt=""
width={step.width}
height={step.height}
className="h-16 w-auto object-contain transition-transform duration-300 group-hover:scale-110"
/>
<p className="font-semibold text-body text-text-primary">{step.title}</p>
<p className="text-body-sm text-text-primary text-center">{step.desc}</p>
</RevealItem>
{i < steps.length - 1 && (
<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>
))}
</RevealGroup>
</section>
);
}
+116
View File
@@ -0,0 +1,116 @@
import Image from "next/image";
import { AddToCartButton } from "../../components/AddToCartButton";
import { Reveal } from "../../components/Reveal";
import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { formatPrice, discountPercent } from "../../lib/format";
import { effectiveTaxRate } from "../../lib/cartTotals";
// Only visually-verifiable claims — same reasoning as Focus.tsx's bullets
// (no engraving/color claim, both unconfirmed as of 2026-08-24).
const bullets = [
"Kugelschreiber aus Metall",
"Chromfarbene Details",
"Handlich, für jede Tasche",
];
// Price/photo come from Payload (same "stift-kugelschreiber" product the
// shop/cart use), not a hardcoded literal — same reasoning as
// todo-cards/components/Pricing.tsx.
export async function Pricing() {
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getProductBySlug("stift-kugelschreiber"),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
if (!product) return null;
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
const fullyOutOfStock =
!product.active || (product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock);
const anyLowStock = product.active && (product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock);
return (
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-8">
<Reveal className="bg-bg-muted rounded-md flex flex-col lg:flex-row gap-8 lg:gap-12 items-center p-6 lg:pl-8 lg:pr-10 lg:py-6">
{/* object-contain on a bg-bg-base tile, not object-cover — the
source is a 338×338px supplier macro shot (no larger original
exists), so stretching it across a 410×227 landscape crop like
before blows it up and blurs it. A contained square reads as a
deliberate close-up instead. Uses product.image (id 85, the
confident macro tip shot) — distinct from Focus.tsx's photo
(gallery[1], the chrome-rings shot) so the two sections don't
repeat the same frame. */}
<div className="group relative w-full lg:w-[15.625rem] lg:shrink-0 aspect-square rounded-sm overflow-hidden bg-bg-base">
<Image
src={product.image}
alt="Der Alltagsstift"
fill
sizes="(min-width: 1024px) 250px, 100vw"
className="object-contain p-4 transition-transform duration-500 group-hover:scale-105"
/>
{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>
) : (
discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)
)}
</div>
<div className="flex flex-col gap-3 items-start flex-1 min-w-0 w-full">
<p
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Der Alltagsstift
</p>
<ul className="flex flex-col gap-[0.375rem] items-start">
{bullets.map((b) => (
<li key={b} className="flex gap-2 items-center">
<Image alt="" src="/icon-bullet-dot.svg" width={4} height={4} className="size-1 shrink-0" />
<span className="text-body-sm text-text-primary">{b}</span>
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
<div className="flex flex-col gap-1 items-start">
<div className="flex gap-2 items-baseline">
{discount !== null && (
<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>
</div>
<p className="text-label text-text-muted">
{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>
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
<AddToCartButton
label="In den Warenkorb"
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
numericId={product.numericId}
outOfStock={!product.active || product.outOfStock}
maxQty={product.maxQty}
variants={product.active ? product.variants : []}
/>
</div>
</Reveal>
</section>
);
}
+54
View File
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import { Hero } from "./components/Hero";
import { HowItWorks } from "./components/HowItWorks";
import { Focus } from "./components/Focus";
import { Pricing } from "./components/Pricing";
import { Footer } from "../components/Footer";
import { getProductBySlug, getCompanySettings } from "../lib/payload";
import { buildProductSchema } from "../lib/structuredData";
const title = "Der Alltagsstift Ein Stift, der einfach da ist, wenn du ihn brauchst.";
const description =
"Der Kugelschreiber für die eine Sache, die heute zählt. Schlicht, hochwertig und immer griffbereit.";
export const metadata: Metadata = {
title,
description,
alternates: {
canonical: "/der-alltagsstift",
},
openGraph: {
title,
description,
url: "/der-alltagsstift",
},
twitter: {
title,
description,
},
};
export default async function DerAlltagsstiftPage() {
const [product, seller] = await Promise.all([
getProductBySlug("stift-kugelschreiber"),
getCompanySettings(),
]);
const productSchema = product
? buildProductSchema(product, "https://einfach-produktiv.mk360.de/der-alltagsstift", seller)
: null;
return (
<>
{productSchema && (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
)}
<main className="flex flex-col flex-1">
<Hero />
<HowItWorks />
<Focus />
<Pricing />
</main>
<Footer />
</>
);
}
@@ -0,0 +1,52 @@
import Link from "next/link";
import { Reveal } from "../../components/Reveal";
// No product photo exists for this page (unlike /todo-cards, /lebensuhr) —
// this is a free monthly ritual, not something with packaging to shoot.
// Text + a Caveat-font quote carries the hero instead, same treatment
// RichText.tsx's Quote component already uses for a callout line.
export function DieSiebenHero() {
return (
<section className="bg-bg-base w-full">
<Reveal className="flex flex-col gap-6 items-start px-[var(--layout-padding-x)] pt-10 pb-12 sm:pt-12 sm:pb-16 max-w-[46rem] mx-auto text-center sm:items-center">
{/* Breadcrumb — left-aligned regardless of the centered content
below, same "not part of the centered block" split as
/todo-cards's and /lebensuhr's own hero. */}
<p className="self-start flex items-center gap-2 text-body-sm text-text-muted">
<Link href="/" className="hover:text-brand transition-colors">
Startseite
</Link>
<span></span>
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
Werkzeuge
</Link>
<span></span>
<span className="text-text-primary">Die Sieben</span>
</p>
<div className="flex flex-col gap-3 items-center">
<p
className="font-semibold text-display text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Die Sieben<span className="text-brand">.</span>
</p>
<p className="font-semibold text-h4 text-text-muted" style={{ fontFamily: "var(--font-lora)" }}>
Das Monatsritual von einfach produktiv
</p>
</div>
<p className="text-body text-text-body max-w-[32rem]">
Sieben kleine Dinge, die den Monat ein bisschen reicher machen.
</p>
<p
className="text-text-primary text-[1.75rem] leading-[1.2] mt-2"
style={{ fontFamily: "var(--font-caveat)" }}
>
&bdquo;Nicht alles, was zählt, steht auf einer ToDo-Liste.&ldquo;
</p>
</Reveal>
</section>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Reveal } from "../../components/Reveal";
export function Philosophie() {
return (
<section className="w-full bg-bg-muted">
<Reveal className="flex flex-col gap-6 items-start px-[var(--layout-padding-x)] py-14 sm:py-16 max-w-[42rem] mx-auto">
<p
className="font-semibold text-h-emphasis text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
Worum es geht
</p>
<p className="text-body text-text-body">
Am Ende eines Monats weiß ich meistens ziemlich genau, was ich erledigt habe. Rechnungen
raus, Elternabend überstanden, drei Umzugskartons endlich ausgepackt, die seit Ostern im
Flur standen. Was ich in dieser Zeit eigentlich erlebt habe, kann ich dagegen kaum noch
erzählen. Irgendwann saß ich abends auf dem Sofa und mir fiel nichts ein, worüber ich
mich in den letzten vier Wochen wirklich gefreut hatte. Nicht, weil nichts Schönes
passiert wäre. Sondern weil ich es im Vorbeigehen nicht bemerkt hatte.
</p>
<p className="text-body text-text-body">
Daraus ist Die Sieben entstanden. Jeden Monat gibt es sieben kleine Einladungen nichts
Kompliziertes, kein Kurs, keine App, die man täglich öffnen muss. Mal ist es die Idee,
jemandem eine Sprachnachricht zu schicken statt einer Textnachricht. Mal geht es darum,
einmal in der Woche ohne Handy zu frühstücken, oder einen Ort in der eigenen Stadt zu
besuchen, an dem man noch nie war. Klein genug, dass man es wirklich macht. Konkret genug,
dass es nicht bei der guten Absicht steckenbleibt.
</p>
<p className="text-body text-text-body">
Man nimmt sich, was gerade passt. Eine Einladung, drei oder alle sieben es gibt kein
Richtig und kein Falsch dabei, keine Haken, die gesetzt werden müssen. Was nicht passt,
lässt man liegen, ohne schlechtes Gewissen. Die Sieben will nicht, dass du produktiver
wirst. Sie will, dass du am Ende des Monats mehr zu erzählen hast als nur, was fertig
geworden ist.
</p>
<p
className="font-semibold text-body text-text-primary border-l-2 border-brand pl-4"
>
Es geht nicht darum, möglichst viel zu erleben, sondern die Dinge bewusster
wahrzunehmen, die ohnehin schon da sind.
</p>
</Reveal>
</section>
);
}
@@ -0,0 +1,97 @@
import { Fragment } from "react";
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
import { StepArrow } from "../../components/StepArrow";
// Hand-drawn inline SVGs, brand-orange stroke — same convention as
// /lebensuhr's own icon set (IconEnvelope etc.), used here instead of a
// photo since there's no product to shoot for a free monthly ritual.
function IconHand() {
return (
<svg width="55" height="48" viewBox="0 0 60 52" fill="none">
<rect x="38" y="12" width="6" height="18" rx="3" stroke="#f6a701" strokeWidth="2" />
<rect x="30" y="6" width="6" height="24" rx="3" stroke="#f6a701" strokeWidth="2" />
<rect x="22" y="10" width="6" height="20" rx="3" stroke="#f6a701" strokeWidth="2" />
<rect x="14" y="16" width="6" height="14" rx="3" stroke="#f6a701" strokeWidth="2" />
<rect x="12" y="28" width="34" height="18" rx="9" stroke="#f6a701" strokeWidth="2" />
<rect x="2" y="30" width="14" height="8" rx="4" stroke="#f6a701" strokeWidth="2" transform="rotate(-25 9 34)" />
</svg>
);
}
function IconEye() {
return (
<svg width="52" height="34" viewBox="0 0 52 34" fill="none">
<path
d="M2 17S12 2 26 2s24 15 24 15-10 15-24 15S2 17 2 17Z"
stroke="#f6a701"
strokeWidth="2"
strokeLinejoin="round"
/>
<circle cx="26" cy="17" r="7" stroke="#f6a701" strokeWidth="2" />
</svg>
);
}
function IconShare() {
return (
<svg width="44" height="48" viewBox="0 0 44 48" fill="none">
<circle cx="8" cy="24" r="6" stroke="#f6a701" strokeWidth="2" />
<circle cx="36" cy="8" r="6" stroke="#f6a701" strokeWidth="2" />
<circle cx="36" cy="40" r="6" stroke="#f6a701" strokeWidth="2" />
<path d="M13.5 21 30.5 11M13.5 27l17 10" stroke="#f6a701" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
const punkte = [
{
icon: <IconHand />,
title: "Nimm dir, was passt",
desc: "Eine Einladung, drei oder alle sieben es gibt kein Richtig oder Falsch dabei.",
},
{
icon: <IconEye />,
title: "Nichts zum Abhaken",
desc: "Die Sieben ist kein Programm. Es reicht, die Dinge bewusst wahrzunehmen.",
},
{
icon: <IconShare />,
title: "Teilen, wenn du magst",
desc: "Manche erzählen anderen von ihren Sieben nicht um sich zu messen, sondern um sich zu erinnern.",
},
];
export function SoFunktionierts() {
return (
<section className="w-full bg-bg-base flex flex-col gap-12 items-center py-14 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"
style={{ fontFamily: "var(--font-lora)" }}
>
So läuft es ab
</p>
<p className="text-body text-text-muted">Jeden Monat neu. Ohne Verpflichtung.</p>
</Reveal>
<RevealGroup className="flex flex-col sm:flex-row gap-8 items-center sm:items-start w-full lg:px-[10rem]">
{punkte.map((punkt, i) => (
<Fragment key={punkt.title}>
<RevealItem className="group flex flex-col gap-4 items-center text-center flex-1 max-w-xs sm:max-w-none">
<div className="flex items-center justify-center h-14 transition-transform duration-300 group-hover:scale-110">
{punkt.icon}
</div>
<p className="font-semibold text-body text-text-primary">{punkt.title}</p>
<p className="text-body-sm text-text-primary text-center">{punkt.desc}</p>
</RevealItem>
{i < punkte.length - 1 && (
<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>
))}
</RevealGroup>
</section>
);
}
+53
View File
@@ -0,0 +1,53 @@
import type { Metadata } from "next";
import { DieSiebenHero } from "./components/DieSiebenHero";
import { Philosophie } from "./components/Philosophie";
import { SoFunktionierts } from "./components/SoFunktionierts";
import { Footer } from "../components/Footer";
import { Newsletter } from "../components/Newsletter";
const title = "Die Sieben Das Monatsritual von einfach produktiv";
const description =
"Sieben kleine Dinge, die den Monat ein bisschen reicher machen. Kein Programm, keine Pflicht nur eine Einladung, bewusster wahrzunehmen, was ohnehin schon da ist.";
export const metadata: Metadata = {
title,
description,
alternates: {
canonical: "/die-sieben",
},
openGraph: {
title,
description,
url: "/die-sieben",
},
twitter: {
title,
description,
},
};
// Rebuilt from the old WordPress page of the same name — a free monthly
// ritual, not a product, so unlike /todo-cards or /lebensuhr there's no
// photo asset and no pricing/testimonials section here. The WordPress
// original also linked out to a monthly-changing "aktuelle Ausgabe" card,
// a printable template, and an archive of past months — none of that has
// a home in this codebase yet (no CMS collection backs it), so this build
// is the evergreen concept page only. Newsletter signup below stands in as
// the "stay in the loop" mechanism until/unless a real "monthly edition"
// content model gets built.
export default function DieSiebenPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<DieSiebenHero />
<Philosophie />
<SoFunktionierts />
<Newsletter
title="Nicht verpassen, wenn eine neue Sieben erscheint"
description="Ich schreibe dir, sobald es die Sieben für den nächsten Monat gibt ohne Spam, jederzeit abbestellbar."
/>
</main>
<Footer />
</>
);
}
@@ -1,12 +1,15 @@
"use client";
import { useState } from "react";
import { useLivePreview } from "@payloadcms/live-preview-react";
import {
renderOrderConfirmationHtml,
renderPasswordResetHtml,
renderOrderStatusHtml,
renderBackInStockHtml,
ORDER_STATUS_EMAIL_ICON,
SAMPLE_ORDER,
SAMPLE_ORDER_MANUAL,
type EmailTemplateContent,
} from "../../../lib/emailTemplates";
import type { EmailTemplateType } from "../../../lib/payload";
@@ -34,26 +37,87 @@ 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(
data,
ORDER_STATUS_EMAIL_ICON[type] ?? "✓",
SAMPLE_ORDER.orderNumber,
`https://einfach-produktiv.mk360.de/konto/bestellungen/${encodeURIComponent(SAMPLE_ORDER.orderNumber)}`,
null,
);
: type === "back-in-stock"
? renderBackInStockHtml(
data,
"ToDo-Karten Set",
"https://einfach-produktiv.mk360.de/todo-cards",
null,
"https://payload.mk360.de/api/media/file/product-todo-karten.png",
)
: renderOrderStatusHtml(
data,
ORDER_STATUS_EMAIL_ICON[type] ?? "✓",
SAMPLE_ORDER.orderNumber,
`https://einfach-produktiv.mk360.de/konto/bestellungen/${encodeURIComponent(SAMPLE_ORDER.orderNumber)}`,
null,
);
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>
);
}
+15
View File
@@ -16,6 +16,11 @@ const VALID_TYPES: EmailTemplateType[] = [
"order-cancelled",
"order-return-requested",
"order-returned",
"order-tracking-added",
"order-tracking-corrected",
"order-delivered",
"payment-method-switched",
"back-in-stock",
];
const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
@@ -23,6 +28,11 @@ 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!",
"back-in-stock": "Wieder da!",
};
// Entered exclusively via EmailTemplates.ts's admin.livePreview.url (a
@@ -31,6 +41,11 @@ const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
// draft:true so an unsaved edit in the admin shows up here immediately;
// the actual sent email (orderEmail.ts) always reads the published version
// instead.
//
// "back-in-stock" has its own renderer (renderBackInStockHtml) rather than
// sharing the generic order-status one below — it has no order at all, so
// no order-number line, and its CTA points at the product page ("Zum
// Produkt"), not /konto/bestellungen.
export default async function EmailPreviewPage({ params }: { params: Promise<{ type: string }> }) {
const { type } = await params;
if (!VALID_TYPES.includes(type as EmailTemplateType)) notFound();
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>
);
}
+46 -10
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 { buildTrackingUrl, CARRIER_LABELS, type Carrier } from "@einfach-produktiv/invoicing";
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 (
<>
@@ -69,6 +80,10 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
<p className="text-label text-text-muted">Status</p>
<OrderStatusBadge status={order.status} />
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Zahlungsstatus</p>
<PaymentStatusBadge paymentStatus={order.paymentStatus} />
</div>
<div className="flex flex-col gap-1">
<p className="text-label text-text-muted">Zahlungsart</p>
<p className="text-body-sm text-text-primary">{order.paymentMethodTitle}</p>
@@ -77,7 +92,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
{order.trackingNumber && (
<div className="flex flex-col gap-1 w-full">
<p className="text-label text-text-muted">Sendungsverfolgung{order.carrier ? ` (${CARRIER_LABELS[order.carrier] ?? order.carrier})` : ""}</p>
<p className="text-label text-text-muted">Sendungsverfolgung{order.carrier ? ` (${CARRIER_LABELS[order.carrier as Carrier] ?? order.carrier})` : ""}</p>
{(() => {
const trackingUrl = buildTrackingUrl(order.carrier, order.trackingNumber);
return trackingUrl ? (
@@ -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,91 @@
"use client";
import { useState } from "react";
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);
const activeCount = [status, paymentStatus, year].filter(Boolean).length;
// Collapsed by default on mobile — with the account tab bar now also
// stacked above this (see KontoShell/AccountNav), 3 full-width dropdowns
// always visible left little room for the actual order list. Desktop
// (sm+) ignores this entirely and always shows the row inline, same as
// before. Starts expanded whenever a filter is already active (arriving
// via a shared/bookmarked filtered URL shouldn't hide what's applied) —
// a lazy initializer since it only needs to run once, on mount.
const [expanded, setExpanded] = useState(() => hasAnyFilter);
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="w-full">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="sm:hidden flex items-center justify-between w-full px-4 py-3 border border-border rounded-sm text-body-sm font-bold text-text-primary"
>
<span className="flex items-center gap-2">
Filter
{activeCount > 0 && (
<span className="flex items-center justify-center min-w-[1.1rem] h-[1.1rem] px-1 rounded-full bg-brand text-[0.6875rem] font-bold leading-none text-text-primary">
{activeCount}
</span>
)}
</span>
<span aria-hidden>{expanded ? "▴" : "▾"}</span>
</button>
<div
className={`${expanded ? "flex" : "hidden"} sm:flex flex-col sm:flex-row sm:items-center gap-3 w-full mt-3 sm:mt-0`}
>
<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>
</div>
);
}
+90 -37
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 { LogoutButton } from "../components/LogoutButton";
import { PaymentStatusBadge } from "../components/PaymentStatusBadge";
import { KontoShell } from "../components/KontoShell";
import { AccountIdentity } from "../components/AccountIdentity";
import { OrderFilters } from "./components/OrderFilters";
// robots: noindex — account area, same reasoning as /checkout.
export const metadata: Metadata = {
@@ -18,50 +27,105 @@ 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 (
<>
<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-[56rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Meine Bestellungen
</p>
<p className="text-body text-text-muted">
Eingeloggt als {session.customer.email} (Kundennummer {session.customer.customerNumber})
</p>
<KontoShell>
<Reveal className="flex flex-col gap-6 items-start w-full">
<div className="flex flex-col gap-1 items-start">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Meine Bestellungen
</p>
<AccountIdentity email={session.customer.email} customerNumber={session.customer.customerNumber} />
</div>
{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,18 +134,7 @@ export default async function KontoBestellungenPage() {
</div>
)}
<div className="flex gap-6">
<Link href="/konto/profil" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Profil &amp; Adresse
</Link>
<Link href="/shop" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Weiter einkaufen
</Link>
<LogoutButton />
</div>
</Reveal>
</main>
<Footer />
</>
</Reveal>
</KontoShell>
);
}
+7
View File
@@ -0,0 +1,7 @@
export function AccountIdentity({ email, customerNumber }: { email: string; customerNumber: string }) {
return (
<p className="text-body-sm text-text-muted">
Eingeloggt als {email} (Kundennummer {customerNumber})
</p>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { clearCart } from "../../lib/cart";
import { dispatchAuthChanged } from "../../lib/auth";
// Persistent account navigation — a fixed sidebar (sm+) / horizontal tab
// bar (below sm) shown on every logged-in /konto/* page via KontoShell.
// Replaces the "Profil & Adresse / Weiter einkaufen / Abmelden" links that
// used to live at the bottom of the orders page's content — those became
// unreachable without scrolling past an arbitrarily long order list. Being
// part of the shell now (not page content), reachability no longer depends
// on how much is above it.
//
// No Merkliste entry — the Navbar's own wishlist heart icon already covers
// that (visible at every width, on every page, not just inside /konto/*),
// so a second entry here would just be a redundant path to the same page.
const NAV_ITEMS = [
{ href: "/konto/bestellungen", label: "Bestellungen" },
{ href: "/konto/profil", label: "Profil" },
] as const;
export function AccountNav() {
const pathname = usePathname();
const router = useRouter();
// Same sequence as the old LogoutButton (now folded in here, its one
// call site): clear the local cart (already mirrored server-side by
// CartSync, so safe to drop — the next login's mergeServerCartIntoLocal()
// restores it), tell already-mounted Client Components (Navbar's
// AccountLink) the auth state changed, leave the account area, then
// force a fresh Server Component render.
async function handleLogout() {
await fetch("/api/account/logout", { method: "POST" });
clearCart();
dispatchAuthChanged();
router.push("/");
router.refresh();
}
return (
<>
{/* Desktop/tablet — sidebar, sm+ (640px). Sits to the left of the
page content inside KontoShell's flex row. */}
<nav className="hidden sm:flex sm:flex-col sm:w-48 shrink-0 gap-1">
<p className="text-label font-bold text-text-muted uppercase tracking-wide px-3 pb-2">Mein Konto</p>
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
className={`px-3 py-2 rounded-sm text-body-sm font-semibold transition-colors ${
pathname.startsWith(item.href) ? "bg-bg-muted text-text-primary" : "text-text-muted hover:text-text-primary"
}`}
>
{item.label}
</Link>
))}
<button
type="button"
onClick={handleLogout}
className="mt-4 px-3 py-2 text-left rounded-sm text-body-sm font-semibold text-red-600 hover:bg-red-50 transition-colors"
>
Abmelden
</button>
</nav>
{/* Mobile — horizontal tab bar, below sm. overflow-x-auto rather
than wrapping: 3 items at once already fits most phones, and a
scrollable single row reads clearly as "more tabs this way"
rather than a wrapped second line competing for attention with
the page content right below it. */}
<nav className="sm:hidden flex items-center gap-2 overflow-x-auto pb-1 -mx-[var(--layout-padding-x)] px-[var(--layout-padding-x)]">
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
className={`shrink-0 px-4 py-2 rounded-full text-body-sm font-bold whitespace-nowrap transition-colors ${
pathname.startsWith(item.href) ? "bg-brand text-text-primary" : "text-text-muted"
}`}
>
{item.label}
</Link>
))}
<button
type="button"
onClick={handleLogout}
className="shrink-0 px-4 py-2 rounded-full text-body-sm font-bold whitespace-nowrap text-red-600"
>
Abmelden
</button>
</nav>
</>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { Footer } from "../../components/Footer";
import { AccountNav } from "./AccountNav";
// Shared shell for every logged-in /konto/* page (bestellungen, merkliste,
// profil) — the <main>/<Footer> wrapping plus the persistent AccountNav
// were previously duplicated per page, with the equivalent of the nav
// buried as plain links at the bottom of the orders page's own content
// (unreachable once the order list got long enough to push it below the
// fold). Each page keeps its own auth-check/redirect and data-fetching
// exactly as before — this only replaces the outer chrome, not the
// page-specific logic each page still needs (e.g. merkliste's return-URL
// login redirect, profil's second profile-fetch redirect).
//
// "Eingeloggt als …" is NOT rendered here (even though `customer` used to
// be the only prop this shell needed) — it belongs directly under each
// page's own title so it reads as "page title, then who's logged in", not
// above the title before the page has even introduced itself. See
// AccountIdentity.tsx, rendered by each page right after its own heading.
export async function KontoShell({ children }: { children: ReactNode }) {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<div className="flex flex-col sm:flex-row gap-6 sm:gap-10 w-full max-w-[75rem] mx-auto px-[var(--layout-padding-x)] pt-8 sm:pt-10 pb-16">
<AccountNav />
<div className="flex-1 min-w-0 flex flex-col gap-6">{children}</div>
</div>
</main>
<Footer />
</>
);
}
-21
View File
@@ -1,21 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { dispatchAuthChanged } from "../../lib/auth";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
await fetch("/api/account/logout", { method: "POST" });
dispatchAuthChanged();
router.push("/");
router.refresh();
}
return (
<button type="button" onClick={handleLogout} className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Abmelden
</button>
);
}
+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,71 @@
"use client";
import { RevealGroup, RevealItem } from "../../../components/Reveal";
import { formatDate } from "../../../lib/format";
import { ProductCard } from "../../../components/ProductCard";
import { useWishlist } from "../../../lib/useWishlist";
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();
// Preserve the wishlist's own order (most-recently-added-first, via
// `items`) rather than initialProducts' own order. Zipped with the
// originating wishlist item (not just the product) so purchasedAt stays
// attached — the earlier `.map().filter()` chain that only kept the
// product lost that association.
const productsByNumericId = new Map(initialProducts.map((p) => [p.numericId, p]));
const visibleEntries = items
.map((item) => ({ item, product: productsByNumericId.get(item.productId) }))
.filter((entry): entry is { item: (typeof items)[number]; product: Product } => Boolean(entry.product));
if (visibleEntries.length === 0) {
return <p className="text-body text-text-muted">Du hast noch keine Produkte gemerkt.</p>;
}
return (
<RevealGroup className="grid items-start grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
{visibleEntries.map(({ item, product }) => (
<RevealItem key={`${product.id}-${item.variant}`}>
<ProductCard
product={product}
defaultTaxRate={defaultTaxRate}
kleinunternehmer={kleinunternehmer}
wishlistEnabled
className="h-full"
// Already-purchased takes precedence over the default
// Ausverkauft/discount badge — a customer who already bought
// this doesn't need a restock notice, they need to know they
// already own it (and can still remove it manually via
// WishlistButton — this is a status note, not an
// auto-removal, see feedback discussion this implements).
// undefined (not purchased) falls through to ProductCard's
// own default Ausverkauft/discount badge.
topLeftBadge={
item.purchasedAt ? (
<span className="absolute top-3 left-3 rounded-full bg-success-subtle px-2.5 py-1 text-label font-bold text-success">
Gekauft am {formatDate(item.purchasedAt)}
</span>
) : undefined
}
/>
</RevealItem>
))}
</RevealGroup>
);
}
+57
View File
@@ -0,0 +1,57 @@
import type { Metadata } from "next";
import { redirect, notFound } from "next/navigation";
import { Reveal } from "../../components/Reveal";
import { getSessionCustomer, getWishlist } from "../../lib/customerAuth";
import { getProductsByIds, getWishlistEnabled, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { KontoShell } from "../components/KontoShell";
import { AccountIdentity } from "../components/AccountIdentity";
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();
// ?redirect= back to this page — landing on the generic account/orders
// overview after logging in from the wishlist icon (the common entry
// point, unlike most other /konto/* pages that are only ever reached
// once already logged in) would silently lose the wishlist context the
// customer actually came here for.
if (!session) redirect("/konto/login?redirect=%2Fkonto%2Fmerkliste");
// 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 (
<KontoShell>
<Reveal className="flex flex-col gap-6 items-start w-full">
<div className="flex flex-col gap-1 items-start">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Meine Merkliste
</p>
<AccountIdentity email={session.customer.email} customerNumber={session.customer.customerNumber} />
</div>
<MerklisteGrid initialProducts={initialProducts} defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} />
</Reveal>
</KontoShell>
);
}
+146 -48
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 {
@@ -83,14 +103,12 @@ export function ProfileForm({
return (
<Reveal className="flex flex-col gap-6 items-start w-full">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Mein Profil
</p>
<p className="text-body-sm text-text-muted">
{profile.email} · Kundennummer {profile.customerNumber}
</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 +130,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 +157,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>}
+15 -15
View File
@@ -1,9 +1,9 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import Link from "next/link";
import { Footer } from "../../components/Footer";
import { getSessionCustomer, getCustomerProfile } from "../../lib/customerAuth";
import { getShippingCountries } from "../../lib/payload";
import { KontoShell } from "../components/KontoShell";
import { AccountIdentity } from "../components/AccountIdentity";
import { ProfileForm } from "./components/ProfileForm";
import { PasswordForm } from "./components/PasswordForm";
import { VerificationBanner } from "./components/VerificationBanner";
@@ -29,19 +29,19 @@ export default async function KontoProfilPage({
const { verified } = await searchParams;
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<div className="flex flex-col gap-10 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[40rem] mx-auto">
<Link href="/konto/bestellungen" className="text-body-sm text-text-muted hover:text-brand transition-colors">
Meine Bestellungen
</Link>
<VerificationBanner emailVerified={profile.emailVerified} justVerified={verified === "1" || verified === "0" ? verified : undefined} />
<ProfileForm profile={profile} shippingCountries={shippingCountries} />
<PasswordForm email={profile.email} />
<AccountDataSection />
<KontoShell>
<div className="flex flex-col gap-10 items-start w-full max-w-[40rem]">
<div className="flex flex-col gap-1 items-start w-full">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Mein Profil
</p>
<AccountIdentity email={profile.email} customerNumber={session.customer.customerNumber} />
</div>
</main>
<Footer />
</>
<VerificationBanner emailVerified={profile.emailVerified} justVerified={verified === "1" || verified === "0" ? verified : undefined} />
<ProfileForm profile={profile} shippingCountries={shippingCountries} />
<PasswordForm email={profile.email} />
<AccountDataSection />
</div>
</KontoShell>
);
}
@@ -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 />
</>
);
}
+24 -3
View File
@@ -4,7 +4,9 @@ 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 { KlaroConsentManager } from "./components/KlaroConsentManager";
import { getProducts, getSeoSettings, getCompanySettings, getWishlistEnabled, getSearchEnabled, getTrackingCodes } from "./lib/payload";
import { buildOrganizationSchema } from "./lib/structuredData";
const inter = Inter({
variable: "--font-inter",
@@ -54,6 +56,9 @@ export async function generateMetadata(): Promise<Metadata> {
card: "summary_large_image",
images: seo.defaultOgImage ? [seo.defaultOgImage] : undefined,
},
verification: seo.googleSearchConsoleVerification
? { google: seo.googleSearchConsoleVerification }
: undefined,
};
}
@@ -66,8 +71,22 @@ 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, trackingCodes] = await Promise.all([
getProducts(),
getCompanySettings(),
getWishlistEnabled(),
getSearchEnabled(),
getTrackingCodes(),
]);
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 +94,11 @@ 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) }} />
<KlaroConsentManager codes={trackingCodes} />
<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,
+5
View File
@@ -4,11 +4,13 @@ import type { Product } from "../payload";
const product = (overrides: Partial<Product> = {}): Product => ({
id: "todo-karten",
numericId: 1,
name: "ToDo-Karten",
description: "",
price: 12.9,
compareAtPrice: null,
image: "",
gallery: [],
href: null,
active: true,
updatedAt: new Date().toISOString(),
@@ -17,11 +19,14 @@ 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,
categories: [],
...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}` };
+69 -8
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,17 +127,76 @@ 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.
//
// Always pushes the resulting cart to the server itself at the end,
// explicitly — does NOT rely on CartSync's own change-triggered push.
// That was the bug (confirmed live 2026-08-01): logging in with an empty
// server cart but a non-empty guest cart means the merge loop below never
// actually runs (nothing in `data.cart` to add), so `writeCart` is never
// called, no change event fires, and CartSync's effect — which only
// re-fires when the cart *reference* actually changes — never pushes the
// guest cart to the server at all. The cart looked fine on the device
// that was already logged in, but never reached any other device. An
// explicit push here doesn't depend on anything having changed.
export async function mergeServerCartIntoLocal(): Promise<void> {
try {
const res = await fetch("/api/account/cart");
if (res.ok) {
const data: { cart?: CartItem[] } = await res.json();
for (const item of data.cart ?? []) addToCart(item.id, item.qty, item.variant);
}
} catch {
// Best-effort — a failed merge just means the server-side cart stays
// as it was; nothing local is lost either way. Still fall through to
// push below — the local (guest) cart is genuine either way.
}
try {
await fetch("/api/account/cart", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cart: readCart() }),
});
} catch {
// Best-effort — CartSync's own push will retry on the next cart
// change, or the next login-time merge tries again.
}
}
// Cross-device sync for an *already* logged-in session — CartSync.tsx
// only ever pushes local→server, and mergeServerCartIntoLocal() above only
// ever runs at the moment of a fresh login/register, so a customer already
// signed in on device B never picked up a change made on device A. This
// covers that gap: called on mount and on window focus (CartSync.tsx), not
// just at login.
//
// Replaces the local cart outright with the server's — NOT an additive
// merge like mergeServerCartIntoLocal() above (that one is only safe
// right after login, when the local cart can't yet overlap the server
// one, see its own comment). An earlier version of this function only
// added/updated matching lines and never removed a local line the server
// no longer had, which meant a *removal* on device A never reached device
// B (confirmed live 2026-08-01 — adding synced, removing didn't). Server
// wins outright, including "server cart is now empty."
//
// Trade-off: a local addition made in the last few hundred ms — after
// this fetch went out but before CartSync's own 800ms debounce pushed it
// — could theoretically get overwritten by a pull that lands in between.
// Narrow enough (and self-healing on the next change/focus) to accept,
// consistent with every other "best-effort" sync path in this file.
export async function pullServerCart(): Promise<void> {
try {
const res = await fetch("/api/account/cart");
if (!res.ok) return;
const data: { cart?: CartItem[] } = await res.json();
for (const item of data.cart ?? []) addToCart(item.id, item.qty, item.variant);
writeCart(data.cart ?? []);
} catch {
// Best-effort — a failed merge just means the server-side cart stays
// as it was; nothing local is lost either way.
// Best-effort — a failed pull just means this device doesn't see
// another device's changes yet; nothing local is lost either way.
}
}

Some files were not shown because too many files have changed in this diff Show More