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

+31 -3
View File
@@ -1,8 +1,12 @@
"use client";
import { useState } from "react";
import { useRef, useState, type TouchEvent } from "react";
import { PlaceholderImage } from "@/components/placeholder-image";
// Mindest-Wischstrecke in Pixeln, ab der ein Touch-Wisch als Bildwechsel
// gilt statt als (vertikales) Scrollen oder ein leichtes Zittern der Hand.
const SWIPE_THRESHOLD_PX = 40;
type Slide = { src: string } | { tone: "forest" | "cream" | "honey"; label: string };
function isImageSlide(slide: Slide): slide is { src: string } {
@@ -29,13 +33,37 @@ export function ProductGallery({ images, productName }: ProductGalleryProps) {
const slides: Slide[] = images.length > 0 ? images.map((src) => ({ src })) : PLACEHOLDER_SLIDES;
const [activeIndex, setActiveIndex] = useState(0);
const active = slides[activeIndex] ?? slides[0];
const touchStartX = useRef<number | null>(null);
function handleTouchStart(event: TouchEvent) {
touchStartX.current = event.touches[0].clientX;
}
// Wischen auf dem Hauptbild wechselt das Bild — auf dem Handy ist das die
// erwartete Bediengeste fuer eine Produktgalerie, nicht nur ein Tap auf die
// (auf kleinen Bildschirmen ohnehin winzigen) Vorschau-Kacheln darunter.
function handleTouchEnd(event: TouchEvent) {
if (touchStartX.current === null || slides.length <= 1) return;
const deltaX = event.changedTouches[0].clientX - touchStartX.current;
touchStartX.current = null;
if (Math.abs(deltaX) < SWIPE_THRESHOLD_PX) return;
setActiveIndex((current) => {
const direction = deltaX < 0 ? 1 : -1;
return (current + direction + slides.length) % slides.length;
});
}
return (
<div>
<div className="aspect-[4/3] overflow-hidden">
<div
className="aspect-[4/3] overflow-hidden touch-pan-y"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{isImageSlide(active) ? (
// eslint-disable-next-line @next/next/no-img-element -- externe Bildquelle noch unbekannt, next/image braucht dafuer eine feste remotePatterns-Konfiguration.
<img src={active.src} alt={productName} className="w-full h-full object-cover" />
<img src={active.src} alt={productName} className="w-full h-full object-cover" draggable={false} />
) : (
<PlaceholderImage tone={active.tone} label={active.label} className="w-full h-full" />
)}
+38 -24
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useState, useEffect, useRef, type RefObject } from "react";
import { AnimatePresence, motion } from "motion/react";
import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store";
import { useCartStock } from "@/hooks/use-cart-stock";
import { formatPrice } from "@/lib/format";
import { mainNav } from "@/lib/site-config";
import { Container, LinkButton } from "@/components/ui";
@@ -417,6 +418,10 @@ export function CartDrawer() {
const removeItem = useCartStore((s) => s.removeItem);
const total = cartTotalCents(items);
const asideRef = useRef<HTMLElement>(null);
// Live-Bestand fuer die Positionen im Drawer — ohne das wuerde ein
// zwischenzeitlich ausverkaufter Artikel hier unauffaellig weiter als
// normal bestellbar erscheinen (siehe use-cart-stock.ts).
const stock = useCartStock(items.map((item) => item.productId));
useEscapeKey(isOpen, closeCart);
useFocusTrap(isOpen, asideRef);
@@ -457,31 +462,40 @@ export function CartDrawer() {
</p>
) : (
<ul className="divide-y divide-ink/10">
{items.map((item) => (
<li key={item.productId} className="py-4 flex gap-4">
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-sm" />
<div className="flex-1">
<p className="text-sm font-medium text-ink">{item.name}</p>
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
<div className="flex items-center gap-3 mt-2">
<QuantityStepper
quantity={item.quantity}
onChange={(quantity) => setQuantity(item.productId, quantity)}
size="sm"
/>
<button
onClick={() => removeItem(item.productId)}
className="text-xs text-ink/65 hover:text-ink underline"
>
{t.cart.remove}
</button>
{items.map((item) => {
const itemStock = stock?.[item.productId];
return (
<li key={item.productId} className="py-4 flex gap-4">
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-sm" />
<div className="flex-1">
<p className="text-sm font-medium text-ink">{item.name}</p>
<p className="text-xs 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 className="flex items-center gap-3 mt-2">
<QuantityStepper
quantity={item.quantity}
onChange={(quantity) => setQuantity(item.productId, quantity)}
max={itemStock ?? 10}
size="sm"
/>
<button
onClick={() => removeItem(item.productId)}
className="text-xs text-ink/65 hover:text-ink underline"
>
{t.cart.remove}
</button>
</div>
</div>
</div>
<p className="text-sm text-ink">
{formatPrice(item.priceCents * item.quantity)}
</p>
</li>
))}
<p className="text-sm text-ink">
{formatPrice(item.priceCents * item.quantity)}
</p>
</li>
);
})}
</ul>
)}
</div>
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui";
import { formatPrice } from "@/lib/format";
import { useCartStore } from "@/store/cart-store";
import { useI18n } from "@/i18n/i18n-provider";
import type { ProductWithRelations } from "@/lib/data";
/**
* Immer sichtbare Kaufleiste am unteren Bildschirmrand — nur auf Mobilgeraeten
* (md:hidden). Die Produktdetailseite ist mit Bewertungen und "Das koennte
* dir auch gefallen" oft laenger als ein Bildschirm; ohne das muesste man auf
* dem Handy nach dem Lesen der Bewertungen wieder ganz nach oben scrollen, um
* den Warenkorb-Button zu erreichen. Bewusst mit eigenem, schlankem
* Button statt AddToCartButton (dessen Button-Variante ist fest auf volle
* Breite gesetzt) und ohne Mengen-Steller — das haelt die Leiste flach genug,
* um auf kleinen Bildschirmen nicht zu viel Platz zu rauben; die Menge laesst
* sich danach jederzeit im Warenkorb anpassen.
*/
export function StickyAddToCartBar({
product,
inStock,
}: {
product: ProductWithRelations;
inStock: boolean;
}) {
const { t } = useI18n();
const addItem = useCartStore((s) => s.addItem);
const [justAdded, setJustAdded] = useState(false);
return (
<div
className="md:hidden fixed inset-x-0 bottom-0 z-40 border-t border-ink/10 bg-background/95 px-4 py-3 backdrop-blur"
style={{ paddingBottom: "calc(0.75rem + env(safe-area-inset-bottom))" }}
>
<div className="flex items-center gap-4">
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-ink">{product.name}</p>
<p className="text-sm text-forest">{formatPrice(product.priceCents, product.currency)}</p>
</div>
{inStock ? (
<Button
className="flex-shrink-0"
onClick={() => {
addItem(
{
productId: product.id,
slug: product.slug,
name: product.name,
weightGrams: product.weightGrams,
priceCents: product.priceCents,
image: product.images[0] ?? null,
},
1
);
setJustAdded(true);
window.setTimeout(() => setJustAdded(false), 1500);
}}
>
{justAdded ? t.product.added : t.product.addToCart}
</Button>
) : (
<span className="flex-shrink-0 text-xs tracking-wide-caps text-ink/50">{t.productPage.soldOut}</span>
)}
</div>
</div>
);
}