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:
1 parent
54919d82ed
commit
0b2196460a
16 files changed
+445
-84
No files matched your search
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user