feat: Kaufjourney-Optimierungen fuer Kund:innen (Shop, Produktseite, Warenkorb, Checkout)

Konkrete Reibungspunkte entlang der Kauf-Journey behoben:
- Shop-Suche als sichtbares, editierbares Feld im Filter-Formular statt nur
  in der kleinen Kopfzeilen-Suche — Suche/Kategorie/Sortierung/Verfuegbarkeit
  lassen sich jetzt in beide Richtungen kombinieren, ohne sich gegenseitig
  zurueckzusetzen; plus "Suche zuruecksetzen"-Link.
- Versandkosten- und Lieferzeit-Hinweis direkt auf der Produktdetailseite
  (bisher erst im Warenkorb/Checkout sichtbar).
- Produktgalerie per Touch-Wisch bedienbar, nicht nur ueber die
  Vorschau-Kacheln (mobile Kaufentscheidung).
- Immer sichtbare mobile Kaufleiste auf der Produktseite, damit der
  Warenkorb-Button auf langen Seiten (Bewertungen, verwandte Produkte) nicht
  wieder erst erscrollt werden muss.
- Live-Lagerbestand (Ausverkauft/Nur noch N) jetzt auch im Cart-Drawer und
  auf der Warenkorb-Seite sichtbar, nicht nur auf der Produktkarte — neuer
  Endpunkt /api/shop/cart-stock + useCartStock-Hook.
- Checkout- und Adressformulare: autoComplete/inputMode/type auf allen
  Feldern (given-name, tel, postal-code, ...) fuer Mobil-Autofill und
  passende Bildschirmtastaturen.
- Bestellpositionen in "Meine Bestellungen" und der Bestellverfolgung
  verlinken jetzt zur Produktseite (samt Bewertungs-Anker) — bisher war die
  Bewertungsfunktion nach dem Kauf nur durch manuelles Zurueeknavigieren
  auffindbar.

Neue Tests fuer die Lagerbestand-Hinweise im Warenkorb; tsc/vitest/build
bleiben gruen (142 Tests).
This commit is contained in:
Nick Szaunig committed 2026-09-05 18:02:38 +02:00
1 parent 54919d82ed
commit 0b2196460a
16 files changed
+445 -84

No files matched your search

+40 -26
View File
@@ -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 { useCartStock } from "@/hooks/use-cart-stock";
import { formatPrice } from "@/lib/format";
import { resolveShippingCostCents, getNextShippingTier, type ShippingTierLike } from "@/lib/shipping-calc";
import { resolveBulkDiscount, getNextBulkDiscountTier, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
@@ -28,6 +29,10 @@ export function WarenkorbContent({
const items = useCartStore((s) => s.items);
const setQuantity = useCartStore((s) => s.setQuantity);
const removeItem = useCartStore((s) => s.removeItem);
// Live-Bestand je Position — derselbe Hinweis wie auf der Produktkarte/
// -detailseite, aber hier fuer den ggf. seit dem Hinzufuegen veraenderten
// aktuellen Bestand (siehe use-cart-stock.ts).
const stock = useCartStock(items.map((item) => item.productId));
const subtotal = cartTotalCents(items);
// Gesamtmenge ueber alle Positionen hinweg — mehrere verschiedene Artikel
// werden fuer die Versandstaffel zusammengezaehlt, nicht getrennt bewertet.
@@ -63,33 +68,42 @@ export function WarenkorbContent({
) : (
<div className="mt-14 grid lg:grid-cols-3 gap-14">
<div className="lg:col-span-2 divide-y divide-ink/10 border-y border-ink/10">
{items.map((item) => (
<div key={item.productId} className="py-6 flex flex-wrap items-center gap-x-6 gap-y-3">
<CartItemThumbnail image={item.image} className="w-20 h-20 flex-shrink-0" />
<div className="flex-1 min-w-[160px]">
<Link href={`/honig/${item.slug}`} className="text-lg text-forest">
{item.name}
</Link>
<p className="text-sm text-ink/65">{item.weightGrams} g</p>
{items.map((item) => {
const itemStock = stock?.[item.productId];
return (
<div key={item.productId} className="py-6 flex flex-wrap items-center gap-x-6 gap-y-3">
<CartItemThumbnail image={item.image} className="w-20 h-20 flex-shrink-0" />
<div className="flex-1 min-w-[160px]">
<Link href={`/honig/${item.slug}`} className="text-lg text-forest">
{item.name}
</Link>
<p className="text-sm text-ink/65">{item.weightGrams} g</p>
{itemStock !== undefined && itemStock === 0 ? (
<p className="mt-1 text-xs text-red-700">{t.product.outOfStock}</p>
) : itemStock !== undefined && itemStock <= 5 ? (
<p className="mt-1 text-xs text-honey-dark">{t.product.onlyLeft(itemStock)}</p>
) : null}
</div>
<div className="flex items-center gap-4 ml-auto">
<QuantityStepper
quantity={item.quantity}
onChange={(quantity) => setQuantity(item.productId, quantity)}
max={itemStock ?? 10}
/>
<span className="w-20 text-right text-ink">
{formatPrice(item.priceCents * item.quantity)}
</span>
<button
onClick={() => removeItem(item.productId)}
aria-label={t.cart.remove}
className="text-ink/65 hover:text-ink"
>
✕
</button>
</div>
</div>
<div className="flex items-center gap-4 ml-auto">
<QuantityStepper
quantity={item.quantity}
onChange={(quantity) => setQuantity(item.productId, quantity)}
/>
<span className="w-20 text-right text-ink">
{formatPrice(item.priceCents * item.quantity)}
</span>
<button
onClick={() => removeItem(item.productId)}
aria-label={t.cart.remove}
className="text-ink/65 hover:text-ink"
>
✕
</button>
</div>
</div>
))}
);
})}
</div>
<div className="border border-ink/10 p-8 h-fit">