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).
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).
- Hero CTA: back to a single line below lg: (wrapping put the icon
beside two lines and looked broken), sized down instead so the full
phrase fits.
- Hero heading: dropped the forced <br> after "darf" at true mobile
widths, kept it only for the sm-lg tablet range it was added for.
- Hero social proof: avatars+text now wrap and center instead of
cramming onto one row (flex-wrap + justify-center, no fixed
breakpoint needed).
- Newsletter card icon: stacked above the copy and centered below md:
instead of squeezed into a row beside it.
- TrustRow: left-aligned below md: instead of centered (shop, cart, and
every other page using this component).
- Cart line item image: full width below sm: instead of a fixed 150px
square.
- Challenge EmailCapture (top + bottom CTA, same shared component):
input and button stack full-width below sm: instead of squeezing
into one row.
- Challenge steps: icon above text and centered at every breakpoint
(previously only from lg: up), connector arrows centered to match.
Checkout forces 0% VAT without de-grossing prices when the tenant is a
Kleinunternehmer (a business decision, not just an engineering default —
unlike the existing intra-community VAT exemption, which does de-gross).
Snapshotted onto the order at checkout time so a later toggle of the
company-settings checkbox never rewrites an already-issued invoice's tax
treatment — same reasoning as the existing vatExempt field.
Threaded through: checkout route, order creation/confirmation email,
on-demand invoice/Storno/Gutschrift downloads, the Bestellbestätigung
page, and the account order-detail page. The four storefront "inkl. X%
MwSt." price hints (shop grid, cart upsell, ToDo-Karten landing page,
homepage spotlight) drop that clause live when the setting is on. The
company-settings Live Preview reflects the checkbox in real time too.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Stock was only checked at checkout; a shopper could add more of a
product to the cart than was actually in stock and only find out at
the last step. Product/variant now carry a real maxQty, and
AddToCartButton/AddToCartInlineButton/the cart's quantity stepper all
disable or cap once the cart already holds that many.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Phase 0 of the e-invoicing migration plan (see the E-Rechnung planning
session) — moves invoicePdf.tsx, correctionInvoicePdf.tsx, and
taxBreakdown.ts into a new shared package, consumed as a git dependency
by both this repo and the payload backend, instead of hand-duplicating
the correction-invoice logic between them (see that package's own README
for the three real drifts the duplication had already caused).
Consumed as raw TS/TSX source via next.config.ts's transpilePackages, not
a pre-built package. Needs `git` in the Docker deps stage and a
project .npmrc (allow-git=root) to let npm ci fetch a git-URL dependency
at all — npm 12+ disables that by default.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The image-overlaid pill made the low-stock message read as clutter on
product photos and had no equivalent in the cart at all. It's now a text
line next to the price (ProductGrid/ProductSpotlight/RelatedProducts/
Pricing) and under the product name in cart line items (variant-specific,
not "any variant low"). The line's height is always reserved, not
conditionally rendered, so cards in the same row stay equal-height
regardless of low-stock state — the exact regression an earlier text-based
version of this hint caused before it was replaced by the image badge.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ausverkauft/discount/low-stock badges used a single either/or slot, so a
product with an active discount silently never showed its low-stock pill
(caught live: todo-karten had both at once). Badges now stack in a flex
column across ProductGrid/ProductSpotlight/RelatedProducts/Pricing, with
Ausverkauft still winning outright. READMEs updated for this and the
recent discount-field gating, shipping-address, and money-rounding changes.
- Removed the inline "Nur noch wenige verfügbar" text hint from
AddToCartButton/AddToCartInlineButton (was making card heights vary in
every grid that renders them — RelatedProducts, ProductSpotlight's CTA
row) — now only shown via the same image-overlaid pill badge
Ausverkauft/discount already use (position: absolute, doesn't affect
layout). Added that badge to RelatedProducts.tsx and todo-cards'
Pricing.tsx, which didn't have it before.
- RelatedProducts cards now also show "inkl. X% MwSt." (was missing
entirely)
- VatBreakdown rows are now flex rows with a spacer instead of plain
text, so every € amount right-aligns to the same edge regardless of
how many digits the rate itself has (was visibly staggered with mixed
7%/19% rates)
- Cart's manual discount-code field only renders when Payload actually
has at least one active code right now (lib/discountServer.ts's new
hasActiveDiscountCode()) — no point showing an open field that could
never validate. An already-applied code (e.g. from an older session)
still always shows its own result row regardless.
- Checkout's "1. Rechnungsadresse" no longer offers a Packstation option
— a Packstation isn't a valid billing address for an invoice. Only a
plain street address now; Packstation is only offered on the separate,
optional "Abweichende Lieferadresse" section, which already had its own
address/Packstation toggle.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bug fixes:
- Navbar login/logout state now updates immediately (custom ep-auth-changed
event) instead of requiring a hard reload
- Status-change email links were broken by an un-encoded "#" in the order
number; fixed for all 4 status emails
- Cart discount code: manual input field restored (was removed entirely)
- Quote-label underline now scales with the label's actual text width
- Number Ranges admin list now shows the invoice prefix/counter columns
Pricing & VAT:
- Prices show the real per-product VAT rate ("inkl. X% MwSt.") instead of
a generic disclosure
- Cart/checkout/confirmation totals show the actual € amount of VAT
included, broken down per rate when a cart spans more than one
(new lib/taxBreakdown.ts, shared with the invoice PDF's own math)
- Account order pages gained product thumbnails and the same VAT breakdown
Low-stock warning: a "Nur noch wenige verfügbar" badge/hint across the
shop grid, spotlight, and add-to-cart variant pickers, driven by the
existing lowStockThreshold field (still never exposes raw stock counts).
Invoice PDFs: product thumbnails on every line item, a plain "Netto"
label (rate was redundant, already stated on the MwSt. line below), no
more duplicate USt-IdNr. in the header, and — for a Stornorechnung
specifically — an explicit "Versand" line that was previously only
folded silently into the tax totals.
Checkout:
- Optional deviating shipping address (separate from the billing address
used for the invoice), with its own toggle + address form
- Full checkout draft persistence (name/address/shipping/payment
selections) survives navigating away and back, via localStorage
- Invoice PDF shows a third "Lieferadresse" block when the shipping
address differs from billing
Mobile navigation: fullscreen panel with a circular reveal animation from
the hamburger's corner, replacing the old in-flow accordion drawer; no
login CTA inside it (redundant with the always-visible header icon).
Admin-facing (Payload backend, mirrored where the frontend has a ported
copy of the same renderer): dashboard rebuilt as individual cards, split
into 3 task queues (received/processing/returns) instead of 2, revenue
and order counts now exclude cancelled/returned orders immediately, and
the low-stock alert links to the specific affected product(s) instead of
the unfiltered list. A new immediate email notifies the shop owner the
moment an order comes in, instead of only via the daily digest.
Testimonials admin list now groups by page instead of interleaving all
three grids' entries. ~45 English admin field descriptions translated to
German for consistency.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- ProductGrid/AddToCartInlineButton/AddToCartButton now show "Ausverkauft"
and disable add-to-cart per variant (or product-level with no variants),
derived from trackInventory/stock/allowBackorder via isOutOfStock().
- AddToCartButton (todo-cards Hero+Pricing, homepage spotlight) gains the
same variant <select> AddToCartInlineButton already had — all three call
sites already fetch full product data server-side.
- /api/checkout re-validates stock server-side (depth-in-defense, not just
the disabled button), rejecting when trackInventory is on, allowBackorder
is off, and requested qty exceeds stock.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PL4zfTY1sXc8x5QS6FatM
Completes the frontend half of the Payload backend's variant/inventory/
tracking work (see that repo's own commit):
- **Cart**: CartItem gained an optional `variant?: string` field — every
function that used to match a line by `id` alone (addToCart/
removeFromCart/setQuantity) now matches by `(id, variant)` together via
a shared sameLine() helper, so two lines for the same product with
different variants stay separate entries. `variant` undefined on both
sides (the no-variants case) still matches by simple equality, so every
pre-existing call site keeps working unchanged.
- **Selection UI**: AddToCartInlineButton renders a <select> above the
button when given a non-empty `variants` prop (ProductGrid/
RelatedProducts pass product.variants straight through); defaults to
the first variant.
- **Pricing**: cartTotals.ts's new effectivePrice(entry, product) — a
variant's priceOverride wins over the base product price. Every cart/
checkout/order-confirmation total and per-line price display now goes
through this instead of reading product.price directly (fixes both a
wrong-price bug and a duplicate-React-key bug the old `key={product.id}`
pattern would have had the moment two variants of one product were both
in the cart).
- **Checkout**: re-validates the requested variant server-side (same
"never trust the client" reasoning as price re-derivation) — a variant
name that doesn't exist on that product fails the whole checkout.
variantName snapshots onto orders.items, shown as a parenthetical next
to the product name on the confirmation email, both invoice PDF types,
and the order-detail page.
- **Cross-device cart**: Customers.cart[].variantName (synced via
/api/account/cart) carries the selection through a login/logout cycle,
not just the current session.
Also adds tracking-number display: /konto/bestellungen/[orderNumber]
shows a clickable link when orders.trackingNumber is set, built by a new
app/lib/tracking.ts that mirrors the Payload backend's own copy
byte-for-byte close (same carrier set/URL patterns) so what a customer
sees here matches exactly what the order-shipped email already links to.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Password reset uses Payload's built-in forgot/reset-password flow,
customized to link to this app instead of the Payload admin. Order
confirmation email and the password-reset email's wording both come from
a new Payload email-templates collection, editable without a deploy and
previewable via Live Preview at /email-preview/[type] (same mechanism as
Posts/LegalPages/Testimonials, sample data instead of a real document).
Also: order numbers get a random suffix (prevents guessing, motivated by
a considered-and-deferred guest order-lookup feature); the discount code
field only shows in the cart when a code is actually active (codes now
apply via a ?code= link instead of manual entry); and three navigation
gaps found while testing — no reachable login link with an empty cart, no
logout link anywhere, no way back from profile to order history.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Discount codes:
- New shared lib/cartTotals.ts (computeSubtotal/computeCartTotals) factored
out of the previously-triplicated subtotal/totalSavings/total math in
CartContent/CheckoutContent/BestellbestaetigungContent, extended to also
fold in a discount amount (percent or fixed, clamped so total can't go
negative).
- lib/discount.ts mirrors lib/cart.ts's exact localStorage pattern so an
applied code survives the /cart -> /checkout transition without a second
input field — Checkout only displays it.
- New /api/discount/validate (read-only check) and /api/discount/redeem
(re-validates + increments the redemption counter, called once from
checkout's handlePurchase right before the OrderSnapshot is written).
Both talk to Payload's new discount-codes collection through
lib/discountServer.ts, a server-only module kept separate from
lib/payload.ts on purpose (that file is also imported by "use client"
components; the RSC-boundary break hit earlier this session was exactly
this mistake with next/headers).
- OrderSnapshot gains discountCode/discountAmount so /bestellbestaetigung
displays what was actually applied instead of losing it on recompute.
RelatedProducts: no longer falls back to re-suggesting a product already
in the cart just to pad the grid out to 3 cards — shows only the
genuinely available remainder (down to 1 card), centered in the 12-column
grid instead of left-aligned.
- Products gain `active`/`spotlight*`/`updatedAt` on the base Product type
(folded in from the now-removed separate SpotlightProduct type) so shop
grid, spotlight, and related-products can each filter `.active` from the
same already-fetched list — cart/checkout/order-confirmation/product-
detail pages keep resolving any product regardless of active status.
- getSpotlightProduct() now derives from getProducts() instead of its own
Payload query: with exactly 1 active product, that one IS the spotlight
(overriding any `spotlight` flag elsewhere); otherwise same
most-recently-updated tie-break as before, just computed client-side.
- ProductGrid drops the already-dead SHOP_GRID_EXCLUDE_IDS list in favor of
the same `active` filter, with an empty-state message if 0 active.
- RelatedProducts gates on >=2 active products regardless of cart contents
or how many display slots would otherwise resolve.
- Navbar's "Shop" link becomes an anchor to the homepage spotlight section
(id="spotlight") instead of a real /shop navigation whenever exactly 1
product is active — passed down from the now-async root layout, which
fetches the catalog once for this decision.
The delivery-time range (handling + transit days) was a hardcoded
HANDLING_DAYS/TRANSIT_DAYS_DE pair in lib/shipping.ts — changing it
needed a code deploy. Now sourced from Payload's new Shipping Settings
collection via getShippingSettings(), threaded down as a prop to the
few Client Components (Cart/Checkout/VersandModal) that can't fetch it
themselves, with the old code constants removed.
Also: the delivery-time note is now shown on every purchase CTA (shop
grid, home spotlight, ToDo-Karten hero + pricing panel), not just one
of them — required next to each buy button per Art. 246a §1 Abs.1
Nr.8 EGBGB, not just somewhere reachable via a link. Checkout's
sidebar was missing the "ab 39€ kostenlos" note Cart already had;
that's fixed too, and both now show the delivery-time range on its own
line instead of crammed onto the shipping-cost line.
Related smaller fixes bundled in since they touch the same files:
price/delivery-time spacing tightened into its own group, the
redundant "Sichere Zahlung" note under Cart's checkout button (already
shown via the trustBadges list right below) replaced with "Sichere
SSL-Verschlüsselung" to match Checkout, and ToDo-Karten's pricing panel
no longer shows a premature payment-security note at the add-to-cart
step.
The hide-timer used to fire on a plain phase==="success" timeout,
regardless of whether the banner was actually on screen — if the
threshold was reached while scrolled away, it could hide itself before
the user ever saw it.
Gates the timer on continuous IntersectionObserver visibility
(useInView, no `once`) instead of a lifetime "ever visible" flag — the
latter flips true as soon as the page loads (the banner sits at the
top), which defeats the purpose entirely. The timer now only runs
while the banner is actually in view, and restarts if the user scrolls
away and back before it completes.
"Du sparst -X,XX €" line under Zwischensumme, summing (compareAtPrice
- price) × qty across items with a discount — only shown when > 0,
same conditional-render convention as the rest of the sale-pricing
UI (badge/strikethrough already gated on discountPercent() !== null).
Same fixed-height + -inset-1 treatment as page-bestellbestaetigung's
own testimonial band, and the same class of top-edge boundary
artifact turned up in 404-testimonial-photo.jpg too (invisible in a
downscaled preview, visible once object-cover scales it up in the
browser) — re-cropped 15px further down to clear it.
Also shrunk both pages' testimonial band from h-[20rem]/24rem to
h-[14rem]/16rem — the taller version left too much empty vertical
space around the 3-line quote.
feat(cart): show the -XX% sale badge on cart line-item photos too
Same badge already used in the shop grid/spotlight/todo-cards
pricing panel, now also on /cart's product thumbnails when that
item has a compareAtPrice set.
Products with compareAtPrice set now show a "-XX%" badge over the
image plus a struck-through original price, wherever price is
displayed (shop grid, homepage spotlight, cart line items). Also
switched the "inkl. MwSt. zzgl. Versand" rows on Pricing.tsx and
ProductSpotlight from items-center to items-baseline — with a large
price next to small disclosure text, center alignment left the
small text visibly floating above the price's bottom edge.
The catalog only has 4 products; with 2 already in the cart, the
initial pick's pool shrinks to 2 and pickRandom's slice can't pad
past what's available, so the grid rendered 2 cards instead of 3.
The swap-after-add path already had fallback logic for this exact
case — extracted it into pickWithFallback() and reused it for the
initial pick too.
New Payload collections, all editable without a code deploy:
- TrustBadges: the horizontal Schneller-Versand/Versandkostenfrei/Mit-
Liebe-verpackt row (TrustRow.tsx, now an async server component).
- CartTrustBadges: the Sichere-Zahlung/14-Tage-Rückgaberecht/Nachhaltig-
verpackt sidebar bullets — shared by /cart (title only) and /checkout
(title + description), which previously had two different hardcoded
bullet lists for what's conceptually the same content.
- ShippingMethods: /checkout's Versandart radios. Each method has its own
optional freeShippingThreshold — omitted means "never free" (Express),
not "always free". /cart's FreeShippingBanner now targets the lowest
threshold among active methods instead of a single global constant, and
hides entirely if no active method has one.
- PaymentMethods: /checkout's Zahlungsart radios, icons as an array
(Kreditkarte shows 3 logos, PayPal/Überweisung show 1).
- Products: new spotlight/spotlightHeadline/spotlightText/spotlightImage/
compareAtPrice fields. ProductSpotlight.tsx (homepage) now shows
whichever product has `spotlight` checked instead of being hardcoded to
ToDo-Karten, with its own marketing copy separate from the plain
catalog name/description. AddToCartButton takes an explicit productId
prop now instead of a hardcoded "todo-karten" constant.
- WerkzeugeCards: the homepage's "Meine Werkzeuge" 3-card grid (Tools.tsx,
now async). Icons use a uniform box instead of the previous per-card
hand-tuned width/height/rotation, which only worked for 3 known,
upside-down-authored SVGs — those were re-exported as pre-flipped PNGs.
lib/payload.ts gained getTrustBadges/getCartTrustBadges/getShippingMethods/
getPaymentMethods/getWerkzeugeCards/getSpotlightProduct, all with the same
graceful-empty-array-on-fetch-failure pattern as the existing functions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both /cart's and /checkout's newsletter hint boxes phrased regular
marketing emails as an already-decided outcome ("bekommst du...").
The actual consent checkbox (unchecked by default) only exists on
/checkout's address form, and even there the copy didn't make the
outcome conditional on it. Pre-supposing opt-in like this is unlawful
under UWG/DSGVO (see the ECJ's Planet49 ruling on pre-checked consent) —
reworded both to frame it as conditional/an option, not a promise.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Products now come from Payload's new "products" collection instead of a
hardcoded catalog, same pattern already used for blog posts:
- lib/payload.ts: getProducts()/getProductBySlug() (server-side fetch,
60s ISR)
- New /api/products route so client components (CartContent,
RelatedProducts) can reach the same data without a server-only import
- lib/products.ts: useProducts() hook replacing the old PRODUCTS record
- ProductGrid (/shop) fetches server-side directly; now shows all
catalog products except notizbuch-klarheit (matches Figma's 4-card
page-shop-overview — still cross-sold via RelatedProducts)
- ProductSpotlight and /todo-cards' Pricing now pull price/photo from
the same CMS product instead of a separately hardcoded "12,90 €", so
the two can't silently drift apart
- formatPrice moved to a new lib/format.ts (plain, no "use client") —
Server Components can't call functions exported from a "use client"
module directly, which lib/products.ts now is because of the hook
Also fixes two unrelated bugs surfaced along the way: the add-to-cart
button visibly resizing when its "Hinzugefügt ✓" success state showed
(fixed with a CSS-grid text stack sized to the wider of the two
strings), and removes the now-unused local product images from public/.
Shows "Noch X € bis zum kostenlosen Versand" with a progress bar while
below the threshold; briefly confirms "freigeschaltet ✓" on crossing it,
then auto-hides. Re-appears if the subtotal drops back below the
threshold (e.g. after removing an item).
- New /shop overview, /cart (real cart state via useSyncExternalStore),
/versand (shipping policy page with TOC) and /newsletter detail page
- Cart: quantity/removal, order summary, related-products cross-sell
with randomized picks, VersandModal quick-reference instead of
navigating away, MwSt. disclosure next to unit prices
- Add-to-cart UX: inline success feedback (green state) plus a
fly-to-navbar-cart-icon animation (CartFlyProvider) with a delayed
badge count-up; AddToCartButton no longer navigates straight to /cart
- Shared lib/products.ts catalog and lib/shipping.ts constants (cost,
free-shipping threshold, handling/transit days) so cart, trust badges
and the versand page can never drift apart
- Fix Navbar smooth-scroll easing (ease-out instead of ease-in-out, no
more perceived start delay); compress newsletter modal photo
2.4MB -> 85KB to fix first-open jank