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
+391
-30
No files matched your search
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getProductsByIds } from "@/lib/data";
|
||||||
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
const MAX_IDS_PER_REQUEST = 50;
|
||||||
|
const STOCK_ATTEMPTS_PER_WINDOW = 60;
|
||||||
|
const STOCK_WINDOW_MS = 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktueller Lagerbestand fuer eine Liste von Produkt-IDs — der Warenkorb
|
||||||
|
* (Zustand/localStorage) kennt nur den Stand beim letzten "Hinzufuegen",
|
||||||
|
* nicht den aktuellen. Cart-Drawer und Warenkorb-Seite fragen das hierueber
|
||||||
|
* live ab, damit "Ausverkauft"/"Nur noch N"-Hinweise auch dort erscheinen,
|
||||||
|
* nicht nur auf der Produktkarte/-detailseite (siehe product-card.tsx).
|
||||||
|
* Rein oeffentliche, bereits im Shop sichtbare Bestandsdaten — keine
|
||||||
|
* Authentifizierung noetig, aber wie jeder andere oeffentliche Endpunkt
|
||||||
|
* rate-limitiert.
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const ip = await getClientIp();
|
||||||
|
if (!rateLimit(`cart-stock:${ip}`, STOCK_ATTEMPTS_PER_WINDOW, STOCK_WINDOW_MS)) {
|
||||||
|
return NextResponse.json({ stock: {} }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const idsParam = new URL(request.url).searchParams.get("ids") ?? "";
|
||||||
|
const ids = idsParam
|
||||||
|
.split(",")
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, MAX_IDS_PER_REQUEST);
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return NextResponse.json({ stock: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
const products = await getProductsByIds(ids);
|
||||||
|
const stock: Record<string, number> = {};
|
||||||
|
for (const product of products) {
|
||||||
|
stock[product.id] = product.isActive ? (product.inventory?.quantity ?? 0) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ stock });
|
||||||
|
}
|
||||||
@@ -104,7 +104,10 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
|||||||
<ul className="mt-4 text-sm text-ink/70 space-y-1">
|
<ul className="mt-4 text-sm text-ink/70 space-y-1">
|
||||||
{order.items.map((item) => (
|
{order.items.map((item) => (
|
||||||
<li key={item.id}>
|
<li key={item.id}>
|
||||||
{item.quantity} × {item.productName}
|
{item.quantity} ×{" "}
|
||||||
|
<Link href={`/honig/${item.product.slug}#bewertungen`} className="hover:text-forest hover:underline">
|
||||||
|
{item.productName}
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent, type HTMLAttributes } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||||
import { TrustBadges } from "@/components/trust-badges";
|
import { TrustBadges } from "@/components/trust-badges";
|
||||||
@@ -115,15 +115,37 @@ export function CheckoutForm({
|
|||||||
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.yourDetails} />
|
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.yourDetails} />
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-2 gap-4">
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
<Field name="firstName" label={t.checkoutPage.firstName} defaultValue={initialValues?.firstName} required />
|
<Field
|
||||||
<Field name="lastName" label={t.checkoutPage.lastName} defaultValue={initialValues?.lastName} required />
|
name="firstName"
|
||||||
|
label={t.checkoutPage.firstName}
|
||||||
|
defaultValue={initialValues?.firstName}
|
||||||
|
autoComplete="given-name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="lastName"
|
||||||
|
label={t.checkoutPage.lastName}
|
||||||
|
defaultValue={initialValues?.lastName}
|
||||||
|
autoComplete="family-name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid sm:grid-cols-2 gap-4">
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
<Field name="email" label={t.checkoutPage.email} type="email" defaultValue={initialValues?.email} required />
|
<Field
|
||||||
|
name="email"
|
||||||
|
label={t.checkoutPage.email}
|
||||||
|
type="email"
|
||||||
|
defaultValue={initialValues?.email}
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Field
|
<Field
|
||||||
name="phone"
|
name="phone"
|
||||||
label={t.checkoutPage.phoneOptional}
|
label={t.checkoutPage.phoneOptional}
|
||||||
|
type="tel"
|
||||||
|
inputMode="tel"
|
||||||
defaultValue={initialValues?.phone}
|
defaultValue={initialValues?.phone}
|
||||||
|
autoComplete="tel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,16 +180,35 @@ export function CheckoutForm({
|
|||||||
name="street"
|
name="street"
|
||||||
label={t.checkoutPage.streetLabel}
|
label={t.checkoutPage.streetLabel}
|
||||||
defaultValue={initialValues?.street}
|
defaultValue={initialValues?.street}
|
||||||
|
autoComplete="street-address"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<div className="grid sm:grid-cols-2 gap-4">
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
<Field name="zip" label={t.checkoutPage.zipLabel} defaultValue={initialValues?.zip} required />
|
<Field
|
||||||
<Field name="city" label={t.checkoutPage.cityLabel} defaultValue={initialValues?.city} required />
|
name="zip"
|
||||||
|
label={t.checkoutPage.zipLabel}
|
||||||
|
defaultValue={initialValues?.zip}
|
||||||
|
autoComplete="postal-code"
|
||||||
|
inputMode="numeric"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="city"
|
||||||
|
label={t.checkoutPage.cityLabel}
|
||||||
|
defaultValue={initialValues?.city}
|
||||||
|
autoComplete="address-level2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Field name="discountCode" label={t.checkoutPage.discountCodeLabel} placeholder={t.checkoutPage.discountCodePlaceholder} />
|
<Field
|
||||||
|
name="discountCode"
|
||||||
|
label={t.checkoutPage.discountCodeLabel}
|
||||||
|
placeholder={t.checkoutPage.discountCodePlaceholder}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center gap-2 text-sm text-ink/70 cursor-pointer">
|
<label className="flex items-center gap-2 text-sm text-ink/70 cursor-pointer">
|
||||||
@@ -252,15 +293,21 @@ function Field({
|
|||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
type = "text",
|
type = "text",
|
||||||
|
inputMode,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
autoComplete,
|
||||||
required = false,
|
required = false,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
/** Zeigt auf Mobilgeraeten die passende Bildschirmtastatur (z.B. Ziffernblock fuer die PLZ). */
|
||||||
|
inputMode?: HTMLAttributes<HTMLInputElement>["inputMode"];
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** Browser-/Mobil-Autofill fuer Adress- und Kontaktfelder (siehe MDN autofill-Tokens) — ohne das muss jede Bestellung komplett von Hand eingetippt werden. */
|
||||||
|
autoComplete?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -269,8 +316,10 @@ function Field({
|
|||||||
<input
|
<input
|
||||||
name={name}
|
name={name}
|
||||||
type={type}
|
type={type}
|
||||||
|
inputMode={inputMode}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
autoComplete={autoComplete}
|
||||||
required={required}
|
required={required}
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { RecordProductView } from "@/components/record-product-view";
|
|||||||
import { RecentlyViewedSection } from "@/components/recently-viewed-section";
|
import { RecentlyViewedSection } from "@/components/recently-viewed-section";
|
||||||
import { StarRating } from "@/components/star-rating";
|
import { StarRating } from "@/components/star-rating";
|
||||||
import { TrustBadges } from "@/components/trust-badges";
|
import { TrustBadges } from "@/components/trust-badges";
|
||||||
|
import { StickyAddToCartBar } from "@/components/sticky-add-to-cart-bar";
|
||||||
import { getProductBySlug, getRelatedProducts } from "@/lib/data";
|
import { getProductBySlug, getRelatedProducts } from "@/lib/data";
|
||||||
import { formatPrice, formatPricePerKg, formatDate } from "@/lib/format";
|
import { formatPrice, formatPricePerKg, formatDate } from "@/lib/format";
|
||||||
import { siteConfig } from "@/lib/site-config";
|
import { siteConfig } from "@/lib/site-config";
|
||||||
@@ -16,6 +17,8 @@ import { getCompanyInfo } from "@/lib/company-info";
|
|||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
import { getApprovedReviews, getReviewSummary, getCustomerReview, hasCustomerPurchased } from "@/lib/reviews";
|
import { getApprovedReviews, getReviewSummary, getCustomerReview, hasCustomerPurchased } from "@/lib/reviews";
|
||||||
import { isProductWishlisted } from "@/lib/wishlist";
|
import { isProductWishlisted } from "@/lib/wishlist";
|
||||||
|
import { getShippingTiers } from "@/lib/shipping";
|
||||||
|
import { resolveShippingCostCents } from "@/lib/shipping-calc";
|
||||||
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
|
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -54,7 +57,8 @@ export default async function ProductDetailPage(
|
|||||||
const lowStock = inStock && stock <= 5;
|
const lowStock = inStock && stock <= 5;
|
||||||
|
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
const [reviews, reviewSummary, relatedProducts, ownReview, canReview, isWishlisted, company] = await Promise.all([
|
const [reviews, reviewSummary, relatedProducts, ownReview, canReview, isWishlisted, company, shippingTiers] =
|
||||||
|
await Promise.all([
|
||||||
getApprovedReviews(product.id),
|
getApprovedReviews(product.id),
|
||||||
getReviewSummary(product.id),
|
getReviewSummary(product.id),
|
||||||
getRelatedProducts(product.id, product.category),
|
getRelatedProducts(product.id, product.category),
|
||||||
@@ -62,7 +66,13 @@ export default async function ProductDetailPage(
|
|||||||
customer ? hasCustomerPurchased(product.id, customer.id) : Promise.resolve(false),
|
customer ? hasCustomerPurchased(product.id, customer.id) : Promise.resolve(false),
|
||||||
customer ? isProductWishlisted(product.id, customer.id) : Promise.resolve(false),
|
customer ? isProductWishlisted(product.id, customer.id) : Promise.resolve(false),
|
||||||
getCompanyInfo(),
|
getCompanyInfo(),
|
||||||
|
getShippingTiers(),
|
||||||
]);
|
]);
|
||||||
|
// "Ab"-Preis fuer eine einzelne Einheit — die Staffel, die beim Kauf genau
|
||||||
|
// dieses einen Produkts greifen wuerde. Macht Versandkosten schon auf der
|
||||||
|
// Produktseite sichtbar statt erst im Warenkorb/Checkout (Kaufentscheidung
|
||||||
|
// haengt bei einem kleinen Versandhandel oft direkt daran).
|
||||||
|
const baseShippingCents = resolveShippingCostCents(shippingTiers, 1);
|
||||||
|
|
||||||
const jsonLd = {
|
const jsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
@@ -110,8 +120,9 @@ export default async function ProductDetailPage(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-16">
|
<div className="py-16 pb-28 md:pb-16">
|
||||||
<RecordProductView productId={product.id} />
|
<RecordProductView productId={product.id} />
|
||||||
|
<StickyAddToCartBar product={product} inStock={inStock} />
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
// "<" escapen, damit Produkttexte niemals ein schliessendes
|
// "<" escapen, damit Produkttexte niemals ein schliessendes
|
||||||
@@ -151,6 +162,12 @@ export default async function ProductDetailPage(
|
|||||||
<p className="text-xs text-ink/65">
|
<p className="text-xs text-ink/65">
|
||||||
{formatPricePerKg(product.pricePerKgCents, product.currency)}
|
{formatPricePerKg(product.pricePerKgCents, product.currency)}
|
||||||
</p>
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/versand-zahlung"
|
||||||
|
className="mt-2 inline-block text-xs text-ink/65 underline decoration-ink/30 hover:text-forest hover:decoration-forest"
|
||||||
|
>
|
||||||
|
{t.productPage.shippingHint(baseShippingCents === 0 ? t.cartPage.free : formatPrice(baseShippingCents))}
|
||||||
|
</Link>
|
||||||
|
|
||||||
{isHoney ? (
|
{isHoney ? (
|
||||||
<div className="mt-8 space-y-3">
|
<div className="mt-8 space-y-3">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import type { HTMLAttributes } from "react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
@@ -84,15 +85,27 @@ export default async function KontoAdressenPage(props: PageProps<"/konto/adresse
|
|||||||
<div className="mt-14 border-t border-ink/10 pt-10">
|
<div className="mt-14 border-t border-ink/10 pt-10">
|
||||||
<h2 className="text-lg text-forest mb-6">{t.addressesPage.addNewHeading}</h2>
|
<h2 className="text-lg text-forest mb-6">{t.addressesPage.addNewHeading}</h2>
|
||||||
<form action={addAddressAction} className="space-y-4 max-w-md">
|
<form action={addAddressAction} className="space-y-4 max-w-md">
|
||||||
<Field name="label" label={t.addressesPage.labelField} defaultValue={t.addressesPage.labelDefaultValue} required />
|
<Field
|
||||||
|
name="label"
|
||||||
|
label={t.addressesPage.labelField}
|
||||||
|
defaultValue={t.addressesPage.labelDefaultValue}
|
||||||
|
autoComplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field name="firstName" label={t.account.firstNameLabel} required />
|
<Field name="firstName" label={t.account.firstNameLabel} autoComplete="given-name" required />
|
||||||
<Field name="lastName" label={t.account.lastNameLabel} required />
|
<Field name="lastName" label={t.account.lastNameLabel} autoComplete="family-name" required />
|
||||||
</div>
|
</div>
|
||||||
<Field name="street" label={t.checkoutPage.streetLabel} required />
|
<Field name="street" label={t.checkoutPage.streetLabel} autoComplete="street-address" required />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field name="zip" label={t.checkoutPage.zipLabel} required />
|
<Field
|
||||||
<Field name="city" label={t.checkoutPage.cityLabel} required />
|
name="zip"
|
||||||
|
label={t.checkoutPage.zipLabel}
|
||||||
|
autoComplete="postal-code"
|
||||||
|
inputMode="numeric"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field name="city" label={t.checkoutPage.cityLabel} autoComplete="address-level2" required />
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||||
<input type="checkbox" name="isDefault" />
|
<input type="checkbox" name="isDefault" />
|
||||||
@@ -110,11 +123,16 @@ function Field({
|
|||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
|
autoComplete,
|
||||||
|
inputMode,
|
||||||
required = false,
|
required = false,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
|
/** Browser-/Mobil-Autofill fuer Adressfelder — dieselben Tokens wie im Checkout-Formular. */
|
||||||
|
autoComplete?: string;
|
||||||
|
inputMode?: HTMLAttributes<HTMLInputElement>["inputMode"];
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -123,6 +141,8 @@ function Field({
|
|||||||
<input
|
<input
|
||||||
name={name}
|
name={name}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
inputMode={inputMode}
|
||||||
required={required}
|
required={required}
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -86,7 +86,10 @@ export default async function KontoBestellungenPage(props: PageProps<"/konto/bes
|
|||||||
<ul className="mt-4 text-sm text-ink/70 space-y-1">
|
<ul className="mt-4 text-sm text-ink/70 space-y-1">
|
||||||
{order.items.map((item) => (
|
{order.items.map((item) => (
|
||||||
<li key={item.id}>
|
<li key={item.id}>
|
||||||
{item.quantity} × {item.productName}
|
{item.quantity} ×{" "}
|
||||||
|
<Link href={`/honig/${item.product.slug}#bewertungen`} className="hover:text-forest hover:underline">
|
||||||
|
{item.productName}
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+24
-4
@@ -116,11 +116,25 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
|||||||
|
|
||||||
<div className="mt-6 flex flex-wrap items-center gap-4">
|
<div className="mt-6 flex flex-wrap items-center gap-4">
|
||||||
{/* GET-Formular statt Client-Component: funktioniert ohne JavaScript,
|
{/* GET-Formular statt Client-Component: funktioniert ohne JavaScript,
|
||||||
die Auswahl wird per Seiten-Neuladen als Query-Parameter uebernommen. */}
|
die Auswahl wird per Seiten-Neuladen als Query-Parameter uebernommen.
|
||||||
<form action="/shop" method="GET" className="flex items-center gap-2 text-sm text-ink/70">
|
Die Suche ist bewusst Teil DIESES Formulars (statt nur der kleinen
|
||||||
{query ? <input type="hidden" name="q" value={query} /> : null}
|
Kopfzeilen-Suche): so werden Kategorie/Verfuegbarkeit-Filter beim
|
||||||
|
Aendern der Suche innerhalb der Shop-Seite nicht verworfen — anders
|
||||||
|
als bei der Kopfzeilen-Suche, die immer neu auf /shop startet. */}
|
||||||
|
<form action="/shop" method="GET" className="flex flex-wrap items-center gap-2 text-sm text-ink/70">
|
||||||
{category ? <input type="hidden" name="kategorie" value={category} /> : null}
|
{category ? <input type="hidden" name="kategorie" value={category} /> : null}
|
||||||
{onlyAvailable ? <input type="hidden" name="verfuegbar" value="1" /> : null}
|
{onlyAvailable ? <input type="hidden" name="verfuegbar" value="1" /> : null}
|
||||||
|
<label htmlFor="shop-search" className="sr-only">
|
||||||
|
{t.shop.searchInputLabel}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="shop-search"
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
defaultValue={query ?? ""}
|
||||||
|
placeholder={t.nav.searchPlaceholder}
|
||||||
|
className="border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
|
||||||
|
/>
|
||||||
<label htmlFor="sortierung">{t.shop.sortLabel}</label>
|
<label htmlFor="sortierung">{t.shop.sortLabel}</label>
|
||||||
<select
|
<select
|
||||||
id="sortierung"
|
id="sortierung"
|
||||||
@@ -157,7 +171,13 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
|||||||
|
|
||||||
{query ? (
|
{query ? (
|
||||||
<p className="mt-6 text-sm text-ink/70">
|
<p className="mt-6 text-sm text-ink/70">
|
||||||
{t.shop.searchResults(query, products.length)}
|
{t.shop.searchResults(query, products.length)}{" "}
|
||||||
|
<Link
|
||||||
|
href={buildHref({ kategorie: category, sortierung: sort, verfuegbar: onlyAvailable ? "1" : undefined })}
|
||||||
|
className="underline hover:text-forest"
|
||||||
|
>
|
||||||
|
{t.shop.clearSearch}
|
||||||
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<h2 className="sr-only">{t.shop.productsHeading}</h2>
|
<h2 className="sr-only">{t.shop.productsHeading}</h2>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect, beforeEach } from "vitest";
|
import { describe, test, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import { WarenkorbContent } from "./warenkorb-content";
|
import { WarenkorbContent } from "./warenkorb-content";
|
||||||
import { useCartStore, type CartItem } from "@/store/cart-store";
|
import { useCartStore, type CartItem } from "@/store/cart-store";
|
||||||
import { I18nProvider } from "@/i18n/i18n-provider";
|
import { I18nProvider } from "@/i18n/i18n-provider";
|
||||||
@@ -35,8 +35,16 @@ const item = (overrides: Partial<CartItem> = {}): CartItem => ({
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useCartStore.setState({ items: [], isOpen: false });
|
useCartStore.setState({ items: [], isOpen: false });
|
||||||
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function stubStockFetch(stock: Record<string, number>) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({ json: () => Promise.resolve({ stock }) })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("WarenkorbContent", () => {
|
describe("WarenkorbContent", () => {
|
||||||
test("shows the empty-cart message when there are no items", () => {
|
test("shows the empty-cart message when there are no items", () => {
|
||||||
renderCart([]);
|
renderCart([]);
|
||||||
@@ -62,4 +70,27 @@ describe("WarenkorbContent", () => {
|
|||||||
expect(screen.getByText(/Mengenrabatt/)).toBeInTheDocument();
|
expect(screen.getByText(/Mengenrabatt/)).toBeInTheDocument();
|
||||||
expect(screen.getByText("-3,00 €")).toBeInTheDocument();
|
expect(screen.getByText("-3,00 €")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows an out-of-stock hint when a cart item's live stock has dropped to zero", async () => {
|
||||||
|
stubStockFetch({ p1: 0 });
|
||||||
|
renderCart([item({ quantity: 2 })]);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Ausverkauft")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows a low-stock hint and caps the quantity stepper at the live stock", async () => {
|
||||||
|
stubStockFetch({ p1: 3 });
|
||||||
|
renderCart([item({ quantity: 3 })]);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Nur noch 3")).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(screen.getByLabelText("Menge erhöhen")).toBeDisabled());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows no stock hint while the live stock is still unknown or comfortably available", () => {
|
||||||
|
stubStockFetch({ p1: 20 });
|
||||||
|
renderCart([item({ quantity: 1 })]);
|
||||||
|
|
||||||
|
expect(screen.queryByText("Ausverkauft")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/^Nur noch/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -7,6 +7,7 @@ import { CartItemThumbnail } from "@/components/cart-item-thumbnail";
|
|||||||
import { QuantityStepper } from "@/components/quantity-stepper";
|
import { QuantityStepper } from "@/components/quantity-stepper";
|
||||||
import { ProductCard } from "@/components/product-card";
|
import { ProductCard } from "@/components/product-card";
|
||||||
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
|
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
|
||||||
|
import { useCartStock } from "@/hooks/use-cart-stock";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
import { resolveShippingCostCents, getNextShippingTier, type ShippingTierLike } from "@/lib/shipping-calc";
|
import { resolveShippingCostCents, getNextShippingTier, type ShippingTierLike } from "@/lib/shipping-calc";
|
||||||
import { resolveBulkDiscount, getNextBulkDiscountTier, type BulkDiscountTierLike } from "@/lib/bulk-discount-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 items = useCartStore((s) => s.items);
|
||||||
const setQuantity = useCartStore((s) => s.setQuantity);
|
const setQuantity = useCartStore((s) => s.setQuantity);
|
||||||
const removeItem = useCartStore((s) => s.removeItem);
|
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);
|
const subtotal = cartTotalCents(items);
|
||||||
// Gesamtmenge ueber alle Positionen hinweg — mehrere verschiedene Artikel
|
// Gesamtmenge ueber alle Positionen hinweg — mehrere verschiedene Artikel
|
||||||
// werden fuer die Versandstaffel zusammengezaehlt, nicht getrennt bewertet.
|
// werden fuer die Versandstaffel zusammengezaehlt, nicht getrennt bewertet.
|
||||||
@@ -63,7 +68,9 @@ export function WarenkorbContent({
|
|||||||
) : (
|
) : (
|
||||||
<div className="mt-14 grid lg:grid-cols-3 gap-14">
|
<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">
|
<div className="lg:col-span-2 divide-y divide-ink/10 border-y border-ink/10">
|
||||||
{items.map((item) => (
|
{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">
|
<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" />
|
<CartItemThumbnail image={item.image} className="w-20 h-20 flex-shrink-0" />
|
||||||
<div className="flex-1 min-w-[160px]">
|
<div className="flex-1 min-w-[160px]">
|
||||||
@@ -71,11 +78,17 @@ export function WarenkorbContent({
|
|||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-sm text-ink/65">{item.weightGrams} g</p>
|
<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>
|
||||||
<div className="flex items-center gap-4 ml-auto">
|
<div className="flex items-center gap-4 ml-auto">
|
||||||
<QuantityStepper
|
<QuantityStepper
|
||||||
quantity={item.quantity}
|
quantity={item.quantity}
|
||||||
onChange={(quantity) => setQuantity(item.productId, quantity)}
|
onChange={(quantity) => setQuantity(item.productId, quantity)}
|
||||||
|
max={itemStock ?? 10}
|
||||||
/>
|
/>
|
||||||
<span className="w-20 text-right text-ink">
|
<span className="w-20 text-right text-ink">
|
||||||
{formatPrice(item.priceCents * item.quantity)}
|
{formatPrice(item.priceCents * item.quantity)}
|
||||||
@@ -89,7 +102,8 @@ export function WarenkorbContent({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border border-ink/10 p-8 h-fit">
|
<div className="border border-ink/10 p-8 h-fit">
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState, type TouchEvent } from "react";
|
||||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
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 };
|
type Slide = { src: string } | { tone: "forest" | "cream" | "honey"; label: string };
|
||||||
|
|
||||||
function isImageSlide(slide: Slide): slide is { src: 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 slides: Slide[] = images.length > 0 ? images.map((src) => ({ src })) : PLACEHOLDER_SLIDES;
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const active = slides[activeIndex] ?? slides[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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="aspect-[4/3] overflow-hidden">
|
<div
|
||||||
|
className="aspect-[4/3] overflow-hidden touch-pan-y"
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
{isImageSlide(active) ? (
|
{isImageSlide(active) ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element -- externe Bildquelle noch unbekannt, next/image braucht dafuer eine feste remotePatterns-Konfiguration.
|
// 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" />
|
<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 { useState, useEffect, useRef, type RefObject } from "react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store";
|
import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store";
|
||||||
|
import { useCartStock } from "@/hooks/use-cart-stock";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
import { mainNav } from "@/lib/site-config";
|
import { mainNav } from "@/lib/site-config";
|
||||||
import { Container, LinkButton } from "@/components/ui";
|
import { Container, LinkButton } from "@/components/ui";
|
||||||
@@ -417,6 +418,10 @@ export function CartDrawer() {
|
|||||||
const removeItem = useCartStore((s) => s.removeItem);
|
const removeItem = useCartStore((s) => s.removeItem);
|
||||||
const total = cartTotalCents(items);
|
const total = cartTotalCents(items);
|
||||||
const asideRef = useRef<HTMLElement>(null);
|
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);
|
useEscapeKey(isOpen, closeCart);
|
||||||
useFocusTrap(isOpen, asideRef);
|
useFocusTrap(isOpen, asideRef);
|
||||||
@@ -457,16 +462,24 @@ export function CartDrawer() {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-ink/10">
|
<ul className="divide-y divide-ink/10">
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
|
const itemStock = stock?.[item.productId];
|
||||||
|
return (
|
||||||
<li key={item.productId} className="py-4 flex gap-4">
|
<li key={item.productId} className="py-4 flex gap-4">
|
||||||
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-sm" />
|
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-sm" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-ink">{item.name}</p>
|
<p className="text-sm font-medium text-ink">{item.name}</p>
|
||||||
<p className="text-xs text-ink/65">{item.weightGrams} g</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">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
<QuantityStepper
|
<QuantityStepper
|
||||||
quantity={item.quantity}
|
quantity={item.quantity}
|
||||||
onChange={(quantity) => setQuantity(item.productId, quantity)}
|
onChange={(quantity) => setQuantity(item.productId, quantity)}
|
||||||
|
max={itemStock ?? 10}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -481,7 +494,8 @@ export function CartDrawer() {
|
|||||||
{formatPrice(item.priceCents * item.quantity)}
|
{formatPrice(item.priceCents * item.quantity)}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragt den aktuellen Lagerbestand fuer die uebergebenen Produkt-IDs ab
|
||||||
|
* (/api/shop/cart-stock) — der Warenkorb selbst (Zustand/localStorage) kennt
|
||||||
|
* nur den Stand vom Hinzufuegen, der sich seitdem geaendert haben kann
|
||||||
|
* (verkauft, im Admin-Bereich angepasst, Produkt deaktiviert). Genutzt vom
|
||||||
|
* Cart-Drawer UND der Warenkorb-Seite, damit "Ausverkauft"/"Nur noch
|
||||||
|
* N"-Hinweise ueberall dort erscheinen, wo eine Kaufentscheidung faellt —
|
||||||
|
* nicht nur auf der Produktkarte.
|
||||||
|
*
|
||||||
|
* Gibt `null` zurueck, bis die erste Antwort da ist (verhindert einen
|
||||||
|
* kurzen Ausverkauft-Blitzer fuer tatsaechlich verfuegbare Artikel), danach
|
||||||
|
* eine Map productId -> aktueller Bestand. Fehlt eine ID in der Map (z.B.
|
||||||
|
* Netzwerkfehler), zeigen die Aufrufer bewusst KEINEN Hinweis statt eines
|
||||||
|
* falschen "ausverkauft".
|
||||||
|
*/
|
||||||
|
export function useCartStock(productIds: string[]): Record<string, number> | null {
|
||||||
|
const key = [...productIds].sort().join(",");
|
||||||
|
const [stock, setStock] = useState<Record<string, number> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Leerer Warenkorb: keine Anfrage noetig, der leere Fall wird unten beim
|
||||||
|
// Rueckgabewert direkt (ohne setState im Effekt) behandelt.
|
||||||
|
if (!key) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetch(`/api/shop/cart-stock?ids=${encodeURIComponent(key)}`, { signal: controller.signal })
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data: { stock?: Record<string, number> }) => setStock(data.stock ?? {}))
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
// "key" fasst die relevanten IDs zusammen — er reicht als Abhaengigkeit,
|
||||||
|
// eine neue Anfrage nur bei tatsaechlich geaenderter ID-Menge.
|
||||||
|
}, [key]);
|
||||||
|
|
||||||
|
return key ? stock : {};
|
||||||
|
}
|
||||||
@@ -1332,6 +1332,7 @@ export const de: Dictionary = {
|
|||||||
productPage: {
|
productPage: {
|
||||||
reviewsLink: (count) => `${count} Bewertung${count === 1 ? "" : "en"}`,
|
reviewsLink: (count) => `${count} Bewertung${count === 1 ? "" : "en"}`,
|
||||||
noReviews: "Noch keine Bewertungen",
|
noReviews: "Noch keine Bewertungen",
|
||||||
|
shippingHint: (price) => `Versand ab ${price} · Lieferzeit 2–4 Werktage`,
|
||||||
onlyLeftInStock: (count) => `Nur noch ${count} auf Lager`,
|
onlyLeftInStock: (count) => `Nur noch ${count} auf Lager`,
|
||||||
soldOut: "Ausverkauft",
|
soldOut: "Ausverkauft",
|
||||||
currentlyUnavailable: "Aktuell nicht verfügbar — schau bald wieder vorbei.",
|
currentlyUnavailable: "Aktuell nicht verfügbar — schau bald wieder vorbei.",
|
||||||
@@ -1399,6 +1400,8 @@ export const de: Dictionary = {
|
|||||||
sortAvailability: "Verfügbarkeit",
|
sortAvailability: "Verfügbarkeit",
|
||||||
apply: "Anwenden",
|
apply: "Anwenden",
|
||||||
onlyAvailable: "Nur verfügbare",
|
onlyAvailable: "Nur verfügbare",
|
||||||
|
searchInputLabel: "Im Sortiment suchen",
|
||||||
|
clearSearch: "Suche zurücksetzen",
|
||||||
searchResults: (query, count) => `Suchergebnisse für „${query}“ (${count})`,
|
searchResults: (query, count) => `Suchergebnisse für „${query}“ (${count})`,
|
||||||
noSearchResults: "Keine Treffer für deine Suche.",
|
noSearchResults: "Keine Treffer für deine Suche.",
|
||||||
noProducts: "Aktuell sind hier keine Produkte verfügbar. Schau bald wieder vorbei.",
|
noProducts: "Aktuell sind hier keine Produkte verfügbar. Schau bald wieder vorbei.",
|
||||||
|
|||||||
@@ -1332,6 +1332,7 @@ export const en: Dictionary = {
|
|||||||
productPage: {
|
productPage: {
|
||||||
reviewsLink: (count) => `${count} review${count === 1 ? "" : "s"}`,
|
reviewsLink: (count) => `${count} review${count === 1 ? "" : "s"}`,
|
||||||
noReviews: "No reviews yet",
|
noReviews: "No reviews yet",
|
||||||
|
shippingHint: (price) => `Shipping from ${price} · Delivery in 2–4 business days`,
|
||||||
onlyLeftInStock: (count) => `Only ${count} in stock`,
|
onlyLeftInStock: (count) => `Only ${count} in stock`,
|
||||||
soldOut: "Sold out",
|
soldOut: "Sold out",
|
||||||
currentlyUnavailable: "Currently unavailable — check back soon.",
|
currentlyUnavailable: "Currently unavailable — check back soon.",
|
||||||
@@ -1399,6 +1400,8 @@ export const en: Dictionary = {
|
|||||||
sortAvailability: "Availability",
|
sortAvailability: "Availability",
|
||||||
apply: "Apply",
|
apply: "Apply",
|
||||||
onlyAvailable: "In stock only",
|
onlyAvailable: "In stock only",
|
||||||
|
searchInputLabel: "Search the shop",
|
||||||
|
clearSearch: "Clear search",
|
||||||
searchResults: (query, count) => `Search results for "${query}" (${count})`,
|
searchResults: (query, count) => `Search results for "${query}" (${count})`,
|
||||||
noSearchResults: "No results for your search.",
|
noSearchResults: "No results for your search.",
|
||||||
noProducts: "No products available here right now. Check back soon.",
|
noProducts: "No products available here right now. Check back soon.",
|
||||||
|
|||||||
@@ -1018,6 +1018,7 @@ export interface Dictionary {
|
|||||||
productPage: {
|
productPage: {
|
||||||
reviewsLink: (count: number) => string;
|
reviewsLink: (count: number) => string;
|
||||||
noReviews: string;
|
noReviews: string;
|
||||||
|
shippingHint: (price: string) => string;
|
||||||
onlyLeftInStock: (count: number) => string;
|
onlyLeftInStock: (count: number) => string;
|
||||||
soldOut: string;
|
soldOut: string;
|
||||||
currentlyUnavailable: string;
|
currentlyUnavailable: string;
|
||||||
@@ -1071,6 +1072,8 @@ export interface Dictionary {
|
|||||||
sortAvailability: string;
|
sortAvailability: string;
|
||||||
apply: string;
|
apply: string;
|
||||||
onlyAvailable: string;
|
onlyAvailable: string;
|
||||||
|
searchInputLabel: string;
|
||||||
|
clearSearch: string;
|
||||||
searchResults: (query: string, count: number) => string;
|
searchResults: (query: string, count: number) => string;
|
||||||
noSearchResults: string;
|
noSearchResults: string;
|
||||||
noProducts: string;
|
noProducts: string;
|
||||||
|
|||||||
Reference in new issue
Block a user