126 Commits

Author SHA1 Message Date
Marco 31e4f907f2 Make Redirects.urlPrefix actually gate which route a code resolves under
resolveAndTrackRedirect() now filters on urlPrefix in addition to code,
with each route.ts passing its own literal prefix — previously the field
was admin-display-only, so a code marked "/sticker" in Payload silently
kept resolving under /r/<code> too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5mssdCBir9kyXTmqBjV3h
2026-08-28 13:15:41 +00:00
Marco e882b8b6ac Match block-driven PDP hero/pricing panel to hand-coded siblings visually
Reported: einfach-anfangen's block-built PDP looked visually far off from
todo-cards. Root cause: ProductBlocks.tsx wrapped every block (including
the hero) in one generic padded div, so the hero image lost its
edge-to-edge desktop bleed/hover-scale and neither section had its own
Reveal scroll-in animation, unlike every hand-coded PDP. ProductHero and
ProductPricingPanel now render their own full <section> matching
todo-cards' Hero.tsx/Pricing.tsx exactly (own Reveal, own grid, own
padding) instead of relying on the shared per-block wrapper, which now
only applies to genuinely generic content blocks.

Also wires up the testimonialsRef block for Products.layout (added to the
backend in commit 82583a1) — same async-fetch handling as PageBlocks.tsx,
plus the 'einfach-anfangen' TestimonialsPage value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
2026-08-26 23:24:14 +00:00
Marco 1ab4088bf0 Wire up Products.layout (PDP page builder) frontend
New ProductBlocks.tsx renders a product's layout — delegates to
PageBlocks.tsx's renderPageBlockSync for the 9 block types shared with
Pages.layout, handles productHero/productPricingPanel itself (need live
product/shipping/tax context a generic content page doesn't have).

Converts /einfach-anfangen to render through this instead of its own
Hero/HowItWorks/Focus/Pricing components (now deleted) — the first PDP
built as CMS blocks end to end. der-eine/todo-cards/tasse-die-pause are
untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
2026-08-26 23:12:26 +00:00
Marco 1330e3e621 Show product subline on cards/cart/PDP hero; brand-color trailing period
Follows Products.subline (docker/payload commit ad7156e). Cards and cart
line items showed nothing but the bare name — added the subline under it.
der-eine and todo-cards' hero taglines were hardcoded copy nearly
identical to this new field — switched both to read subline instead, so
future edits go through the CMS.

Also adds a shared ProductName component: every hand-written PDP headline
already styled a trailing "." in brand color, but the few places that
render product.name as plain text (cards, cart, the Payload-driven
tasse-die-pause hero) had silently lost that styling. Centralizes it so
it can't drift per call site again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
2026-08-26 22:26:14 +00:00
Marco 7489f83564 Follow Products.description consolidation to richText-only
Backend dropped the plain-text description field in favor of a single
richText one (see docker/payload commit 1ba8d07). Renders formatted
(bold/italic/multiple paragraphs) via the shared RichText component on
the PDP (der-eine, tasse-die-pause); everywhere else (Passend-dazu cards,
Product JSON-LD, homepage spotlight fallback) derives plain text at read
time via a new extractPlainText() helper instead of a second field.
Removes the description line from cart line items entirely — it only
bloated the cart with no real benefit there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J1Hu5bZ1kZUgKhab6yNwCt
2026-08-26 22:04:28 +00:00
Marco 56fae6ff13 Product JSON-LD: add sku + gallery images, single-source the meta description
sku was tracked in Payload but never exposed to the frontend at all —
added to the Product type and wired into buildProductSchema (a real
Schema.org property Google's rich-result validator checks for).
image is now an array (main + gallery) when a product has gallery
photos, instead of always just the one main image.

/der-eine's page metadata description was a separately hardcoded
string that had drifted from Products.description (the one JSON-LD/
cart/checkout actually use) — now reads from the live product via
generateMetadata, same pattern /tasse-die-pause already uses. Updated
Payload's own description field to the more product-descriptive text
that used to live only in that hardcoded string.
2026-08-26 21:20:44 +00:00
Marco b0d76c891f Frontend support for Hero block's optional portrait image 2026-08-26 20:50:25 +00:00
Marco 098ba79c8f Frontend support for the Hero block; H1 moves out of the hardcoded wrapper
app/[slug]/page.tsx and LivePageContent.tsx no longer render a
hardcoded H1 from page.title — that's now the Hero block's job (see
HeroBlock.ts's own comment on why). Breadcrumb stays, still built
from page.title.

Also backfilled /lebensuhr's already-migrated Pages document with a
Hero block (order 0) carrying its real headline — without it the
page would have lost its H1 entirely once the hardcoded wrapper was
removed.
2026-08-26 20:41:21 +00:00
Marco 2d9508324a Frontend support for stepRow subtitle + new icon block
Mirrors the backend schema additions (StepRowBlock.items.subtitle,
new IconBlock) needed for the /lebensuhr Pages migration: PageBlock
type + mapPayloadPageBlock in payload.ts, a "icon" case in
PageBlocks.tsx, and SYMBOLIC_ICONS (the clock face) added alongside
STEP_ICONS in StepIcons.tsx — a distinct registry since these icons
are standalone, not part of an icon-above-text row.
2026-08-26 20:26:46 +00:00
Marco 885389b300 Add the page-builder frontend: /[slug] catch-all + block renderer
Pages/getPageBySlug/mapPayloadPage in payload.ts follow the exact
getPostBySlug/mapPayloadPost pattern. PageBlocks.tsx renders a Pages
doc's `layout` field, reusing existing components (RichText,
StepArrow, STEP_ICONS, TestimonialsGrid) rather than reinventing per-
block styling — matches what /lebensuhr, /3x3-system, and
/7-tage-klarheits-check already hand-built. LivePageContent.tsx
mirrors LivePostContent.tsx's live-preview pattern, using a synchronous
subset of the block renderer (testimonialsRef needs an async fetch a
client component can't perform inline, so it's skipped in preview
only — same "editable subset" scope-cut LivePostContent.tsx already
makes). buildWebPageSchema in structuredData.ts is the generic
JSON-LD fallback for content types with no bespoke schema (Article/
Product don't fit a page-builder page).

Verified end-to-end: inserted a real Pages test document (SQL, since
no admin auth available here) covering richTextSection/quote/ctaCard,
confirmed /[slug] renders it correctly, confirmed notFound() after
deleting it, then cleaned up the test row.
2026-08-26 20:08:24 +00:00
Marco e2d81fabc7 Add the trailing period to "einfach produktiv." wherever it was missing
The period is part of the brand name (see Footer.tsx's own comment on
the two-tone logo treatment) — fixed every place it was cleanly
appendable (string/heading endings, or before a dash, matching the
existing precedent in VertragspartnerBlock.tsx). Left the handful of
genuinely awkward mid-sentence spots alone (separable-verb endings,
"einfach produktiv-Konto" hyphenated compounds) rather than force a
period that breaks the sentence — flagging those separately.

Also: Über-mich's checklist now uses the same brand checkmark as
/lebensuhr and /7-tage-klarheits-check instead of plain bullets.
2026-08-26 19:25:27 +00:00
Marco c64902c381 Rename /lebensuhr to /7-tage-klarheits-check, update CTA/content copy
Renames the route, its canonical/OG metadata, Navbar's active-route
list, and the testimonials page filter to match. Also updates CTA
copy ("Klarheits-Check starten"), the "So funktioniert" heading, the
"3. Umsetzen" step text, and the breadcrumb label.
2026-08-26 15:04:03 +00:00
Marco 194117c07d Der Eine.: add Testimonials + closing Pricing CTA, reorder PasstDazu last
Testimonials now pulled from Payload (page: "der-eine", same collection
as todo-cards/newsletter/challenge) instead of a hardcoded array. Pricing
adds a second, final buy CTA (same image+price+button card as the other
product pages' Pricing.tsx) for visitors who scroll past Hero's button.
PasstDazu moves to right before the Footer.
2026-08-25 19:28:47 +00:00
Marco 1f537ccb06 PasstDazu driven by Products.relatedProduct; circle back on Focus icons
- PasstDazu.tsx now takes a productSlug prop and reads its cross-sell
  target from the product's own relatedProduct field (Payload) instead
  of hardcoding "todo-karten" — reusable on any product page now.
- payload.ts: Product/PayloadProduct gained relatedProduct, resolved via
  a bounded-recursion mapPayloadProduct call (safe — the fetch's depth:2
  means the nested relation's own relatedProduct is never populated).
- Focus.tsx icons: added the black stroked circle back around each glyph
  (matches icon-step-3.png exactly) — dropped in the previous pass by
  mistake while fixing the color/fill.
2026-08-25 13:45:52 +00:00
Marco 4fa80d6bfb Allow bold formatting in the spotlight product's marketing text
Products.spotlightText switches from a plain textarea to a richText field
(Lexical, same default editor as every other richText field here) so
admins get a formatting toolbar. Rendered via a small dedicated inline
converter (bold + paragraphs only) rather than the full block-oriented
RichText.tsx, which is styled for article-length content (blog posts,
legal pages) and would add heading/paragraph spacing this teaser doesn't
want.
2026-08-25 12:50:00 +00:00
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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