From 0402f7510d20206766c5be1ecc7b2176174d4901 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sun, 6 Sep 2026 03:35:57 +0200 Subject: [PATCH] fix: eliminate checkout/cart layout shift and low-contrast muted text Live Lighthouse + performance-trace audits against the running dev app found two real, measurable defects: - /checkout and /warenkorb rendered the cart-store's default empty state before Zustand's persist middleware finished reading localStorage, so any customer who already had items in their cart saw an "empty cart" flash before the real form appeared (CLS 0.82). Fixed by tracking hasHydrated in the store and rendering the existing route skeleton until rehydration completes. Verified via Chrome DevTools performance trace: CLS 0.82 -> 0.00 on both pages. - axe-core (via Lighthouse) flagged text-ink/60 on the cream background at 4.09:1 contrast, under the 4.5:1 WCAG AA minimum for normal-size text. Bumped to text-ink/70 (5.61:1) everywhere the same token is used at text-xs/text-sm across 7 files. Co-Authored-By: Claude Sonnet 5 --- src/app/charge/[code]/page.tsx | 8 ++++---- src/app/checkout/checkout-form.tsx | 13 ++++++++++++- src/app/warenkorb/warenkorb-content.tsx | 14 +++++++++++--- src/app/wissen/[slug]/page.tsx | 2 +- src/components/password-field.tsx | 2 +- src/components/product-card.tsx | 2 +- src/components/site-chrome.tsx | 2 +- src/store/cart-store.ts | 12 ++++++++++++ 8 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/app/charge/[code]/page.tsx b/src/app/charge/[code]/page.tsx index b7fe17f..dca6e50 100644 --- a/src/app/charge/[code]/page.tsx +++ b/src/app/charge/[code]/page.tsx @@ -49,7 +49,7 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {

{batch.harvest.type} {batch.harvest.year}

-

{t.chargePage.provenanceNote(batch.code)}

+

{t.chargePage.provenanceNote(batch.code)}

) {
-

{step.label}

+

{step.label}

{step.value}

))} @@ -86,7 +86,7 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
-

{t.chargePage.regionLabel}

+

{t.chargePage.regionLabel}

{batch.harvest.location}

{batch.harvest.description}

@@ -95,7 +95,7 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) { {batch.products.length > 0 ? (
-

{t.chargePage.fromThisBatch}

+

{t.chargePage.fromThisBatch}

{batch.products.map((product) => ( s.items); + const hasHydrated = useCartStore((s) => s.hasHydrated); const subtotal = cartTotalCents(items); const totalQuantity = cartItemCount(items); const router = useRouter(); @@ -109,6 +111,15 @@ export function CheckoutForm({ } } + // Der Cart-Store haelt seine Positionen nur im Browser (persist/localStorage) + // und liest sie erst nach dem ersten Client-Render nach. Ohne diese Weiche + // wuerde jede:r Kund:in mit gefuelltem Warenkorb kurz die "leer"-Meldung + // sehen, bevor auf das echte Formular umgeschaltet wird — ein grosser, + // messbarer Layout-Sprung (siehe CLS) und verwirrend fuer echte Kaeufer:innen. + if (!hasHydrated) { + return ; + } + if (items.length === 0) { return (
@@ -179,7 +190,7 @@ export function CheckoutForm({ ))}
{fulfillment === "VERSAND" ? ( -

+

{t.checkoutPage.deliveryTimeNote}

) : null} diff --git a/src/app/warenkorb/warenkorb-content.tsx b/src/app/warenkorb/warenkorb-content.tsx index a499fec..88e38f4 100644 --- a/src/app/warenkorb/warenkorb-content.tsx +++ b/src/app/warenkorb/warenkorb-content.tsx @@ -7,6 +7,7 @@ import { CartItemThumbnail } from "@/components/cart-item-thumbnail"; import { QuantityStepper } from "@/components/quantity-stepper"; import { ProductCard } from "@/components/product-card"; import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store"; +import WarenkorbLoading from "./loading"; import { useCartStock } from "@/hooks/use-cart-stock"; import { formatPrice } from "@/lib/format"; import { resolveShippingCostCents, getNextShippingTier, type ShippingTierLike } from "@/lib/shipping-calc"; @@ -27,6 +28,7 @@ export function WarenkorbContent({ }) { const { t, locale } = useI18n(); const items = useCartStore((s) => s.items); + const hasHydrated = useCartStore((s) => s.hasHydrated); const setQuantity = useCartStore((s) => s.setQuantity); const removeItem = useCartStore((s) => s.removeItem); // Live-Bestand je Position — derselbe Hinweis wie auf der Produktkarte/ @@ -53,6 +55,12 @@ export function WarenkorbContent({ .filter((product) => !cartProductIds.has(product.id)) .slice(0, CROSS_SELL_LIMIT); + // Siehe checkout-form.tsx: ohne diese Weiche flackert kurz die + // "leer"-Meldung auf, bevor der Cart-Store aus localStorage nachliest. + if (!hasHydrated) { + return ; + } + return (
@@ -122,7 +130,7 @@ export function WarenkorbContent({ {shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}
{nextTier && nextTier.unitsUntilNext <= 2 ? ( -

+

{t.cartPage.nextTierHint(nextTier.unitsUntilNext, formatPrice(nextTier.priceCents, "EUR", locale))}

) : null} @@ -134,7 +142,7 @@ export function WarenkorbContent({ -{formatPrice(bulkDiscountCents, "EUR", locale)}
) : nextBulkDiscount && nextBulkDiscount.unitsUntilNext <= 2 ? ( -

+

{t.cartPage.bulkDiscountProgress(nextBulkDiscount.unitsUntilNext, nextBulkDiscount.percent)}

) : null} @@ -146,7 +154,7 @@ export function WarenkorbContent({ {t.cart.checkout} - + {t.cartPage.pickupHint} diff --git a/src/app/wissen/[slug]/page.tsx b/src/app/wissen/[slug]/page.tsx index de79700..3a88636 100644 --- a/src/app/wissen/[slug]/page.tsx +++ b/src/app/wissen/[slug]/page.tsx @@ -86,7 +86,7 @@ export default async function WissenArticlePage( {otherArticles.length > 0 ? (
-

{t.wissenArticlePage.otherArticlesHeading}

+

{t.wissenArticlePage.otherArticlesHeading}

{otherArticles.map((otherSlug) => { const other = t.wissenArticles[otherSlug]; diff --git a/src/components/password-field.tsx b/src/components/password-field.tsx index 3a31352..83858f9 100644 --- a/src/components/password-field.tsx +++ b/src/components/password-field.tsx @@ -123,7 +123,7 @@ export function PasswordField({ /> ))} - {tierLabel} + {tierLabel} ) : null} diff --git a/src/components/product-card.tsx b/src/components/product-card.tsx index 71a63ef..dbdd72d 100644 --- a/src/components/product-card.tsx +++ b/src/components/product-card.tsx @@ -45,7 +45,7 @@ export function ProductTeaserLink({ {name} - + {t.product.viewProduct} diff --git a/src/components/site-chrome.tsx b/src/components/site-chrome.tsx index 5e4f948..1336d78 100644 --- a/src/components/site-chrome.tsx +++ b/src/components/site-chrome.tsx @@ -163,7 +163,7 @@ function SearchButton() { className="flex items-center justify-between gap-4 px-4 py-2.5 text-sm text-ink hover:bg-cream/60" > {s.name} - {s.price} + {s.price} ))} diff --git a/src/store/cart-store.ts b/src/store/cart-store.ts index 7ff8cc7..5d5143a 100644 --- a/src/store/cart-store.ts +++ b/src/store/cart-store.ts @@ -16,6 +16,13 @@ export interface CartItem { interface CartState { items: CartItem[]; isOpen: boolean; + // Client-only: waehrend des Server-Renders und des allerersten Client- + // Renders (bevor persist localStorage gelesen hat) ist dies immer false, + // damit UI, die auf "items.length === 0" verzweigt (Checkout/Warenkorb), + // nicht kurz einen falschen "leerer Warenkorb"-Zustand zeigt, bevor die + // echten, aus localStorage wiederhergestellten Positionen ankommen. + hasHydrated: boolean; + setHasHydrated: (hasHydrated: boolean) => void; openCart: () => void; closeCart: () => void; addItem: (item: Omit, quantity?: number) => void; @@ -29,6 +36,8 @@ export const useCartStore = create()( (set) => ({ items: [], isOpen: false, + hasHydrated: false, + setHasHydrated: (hasHydrated) => set({ hasHydrated }), openCart: () => set({ isOpen: true }), closeCart: () => set({ isOpen: false }), addItem: (item, quantity = 1) => @@ -64,6 +73,9 @@ export const useCartStore = create()( // UI-Zustand und darf nach einem Seiten-Reload nicht wiederhergestellt // werden (sonst poppt das Cart-Drawer nach jedem Reload erneut auf). partialize: (state) => ({ items: state.items }), + onRehydrateStorage: () => (state) => { + state?.setHasHydrated(true); + }, } ) );