fix: deaktivierte Produkte sind nicht mehr bestellbar

Ein Produkt, das im Admin-Bereich auf "Inaktiv" gesetzt wird, verschwand
zwar aus der Shop-Uebersicht und aus der Sitemap — seine Detailseite blieb
aber vollstaendig bestellbar. Erreichbar war sie weiterhin ueber
Direktlink, Lesezeichen, alte Newsletter, "Zuletzt angesehen" und den
Google-Index. getProductBySlug filtert isActive nicht, und die Seite
pruefte es nicht nach.

Auch der Checkout selbst pruefte es nicht: getProductsByIds filtert
bewusst nicht danach (es wird auch fuer bereits bestellte, inzwischen
deaktivierte Produkte genutzt, siehe lib/data.ts), und die Route verliess
sich allein auf den Abgleich der Anzahl gefundener Produkte. Die
Warenkorb-Oberflaeche zeigt ein deaktiviertes Produkt ueber
/api/shop/cart-stock zwar mit Bestand 0 an — aber das ist nur der Client.
Ein Warenkorb, der im Browser lag, bevor das Produkt deaktiviert wurde,
kam ungeprueft durch: die Bestellung wurde angelegt und tatsaechlich
bezahlt, obwohl die Imkerei den Artikel gerade aus dem Verkauf genommen
hatte.

Behoben an beiden Stellen:

- Produktseite: isPurchasable = isActive && Bestand > 0 steuert jetzt
  Bestellbutton, Sticky-Leiste und die schema.org-Verfuegbarkeit. Die
  Seite bleibt bewusst erreichbar statt 404 zu liefern — Bestellhistorie
  und Sendungsverfolgung verlinken hierher zum Bewerten, und wer eine
  ausgelaufene Saisonernte gekauft hat, soll sie weiter bewerten koennen.
  Stattdessen "robots: noindex", damit sie nicht ueber Google als
  vermeintlich bestellbares Produkt gefunden wird.
- Die "Benachrichtige mich"-Anmeldung erscheint nur noch bei einem aktiven,
  aber ausverkauften Produkt. Die Benachrichtigung haengt am Lagerbestand
  (lib/stock-notifications.ts) — bei einem dauerhaft eingestellten Produkt
  waere sie ein Versprechen, das nie eingeloest wird.
- Checkout-Route: lehnt Bestellungen mit deaktivierten Produkten
  serverseitig ab, ueber dieselbe Fehlermeldung wie ein nicht mehr
  vorhandenes Produkt.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-15 10:08:15 +02:00
1 parent 746c12ae68
commit 91aee0d609
2 files changed
+67 -32

No files matched your search

+18
View File
@@ -84,6 +84,24 @@ export async function POST(request: Request) {
); );
} }
// Ein im Admin deaktiviertes Produkt (isActive = false) darf nicht mehr
// verkauft werden. getProductsByIds filtert bewusst nicht danach, weil es
// auch fuer bereits bestellte, inzwischen deaktivierte Produkte genutzt
// wird (siehe lib/data.ts) — die Pruefung gehoert deshalb hierher.
//
// Die Warenkorb-Oberflaeche zeigt ein solches Produkt ueber
// /api/shop/cart-stock zwar bereits mit Bestand 0 an, aber das ist nur der
// Client. Ein Warenkorb, der im Browser lag, bevor das Produkt deaktiviert
// wurde, kam bis hierher ungeprueft durch: die Bestellung wurde angelegt
// und tatsaechlich bezahlt, obwohl die Imkerei den Artikel gerade aus dem
// Verkauf genommen hatte.
if (products.some((product) => !product.isActive)) {
return NextResponse.json(
{ error: "Ein Produkt im Warenkorb ist nicht mehr verfügbar." },
{ status: 400 }
);
}
const orderItemsData = input.items.map((cartItem) => { const orderItemsData = input.items.map((cartItem) => {
const product = products.find((p) => p.id === cartItem.productId)!; const product = products.find((p) => p.id === cartItem.productId)!;
return { return {
+49 -32
View File
@@ -41,6 +41,11 @@ export async function generateMetadata(
title: product.name, title: product.name,
description: `${product.name} — ${product.tasteTags.join(", ")}. ${product.weightGrams} g aus eigener Imkerei in Altenmünster.`, description: `${product.name} — ${product.tasteTags.join(", ")}. ${product.weightGrams} g aus eigener Imkerei in Altenmünster.`,
alternates: { canonical: `/honig/${slug}` }, alternates: { canonical: `/honig/${slug}` },
// Deaktivierte Produkte bleiben erreichbar (die Bestellhistorie und die
// Sendungsverfolgung verlinken hierher zum Bewerten), gehoeren aber nicht
// in den Suchindex: wer ueber Google kaeme, faende ein Produkt, das nicht
// mehr bestellbar ist.
...(product.isActive ? {} : { robots: { index: false, follow: true } }),
}; };
} }
@@ -66,8 +71,12 @@ export default async function ProductDetailPage(
? "was-bedeutet-sommertracht" ? "was-bedeutet-sommertracht"
: null; : null;
const stock = product.inventory?.quantity ?? 0; const stock = product.inventory?.quantity ?? 0;
const inStock = stock > 0; // Ein im Admin deaktiviertes Produkt ist nicht bestellbar, auch wenn noch
const lowStock = inStock && stock <= 5; // Bestand gebucht ist. Es verschwindet bereits aus Shop-Uebersicht und
// Sitemap, seine Detailseite blieb aber voll bestellbar — erreichbar ueber
// Direktlink, Lesezeichen, alte Newsletter und "Zuletzt angesehen".
const isPurchasable = product.isActive && stock > 0;
const lowStock = isPurchasable && stock <= 5;
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
const [reviews, reviewSummary, relatedProducts, ownReview, canReview, isWishlisted, company, shippingTiers] = const [reviews, reviewSummary, relatedProducts, ownReview, canReview, isWishlisted, company, shippingTiers] =
@@ -108,7 +117,7 @@ export default async function ProductDetailPage(
"@type": "Offer", "@type": "Offer",
priceCurrency: product.currency, priceCurrency: product.currency,
price: (product.priceCents / 100).toFixed(2), price: (product.priceCents / 100).toFixed(2),
availability: inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock", availability: isPurchasable ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
url: `${base}/honig/${product.slug}`, url: `${base}/honig/${product.slug}`,
}, },
...(reviewSummary.count > 0 ...(reviewSummary.count > 0
@@ -144,7 +153,7 @@ export default async function ProductDetailPage(
return ( return (
<div className="py-16 pb-28 md:pb-16"> <div className="py-16 pb-28 md:pb-16">
<RecordProductView productId={product.id} /> <RecordProductView productId={product.id} />
<StickyAddToCartBar product={product} inStock={inStock} /> <StickyAddToCartBar product={product} inStock={isPurchasable} />
<script <script
type="application/ld+json" type="application/ld+json"
// "<" escapen, damit Produkttexte niemals ein schliessendes // "<" escapen, damit Produkttexte niemals ein schliessendes
@@ -206,7 +215,7 @@ export default async function ProductDetailPage(
</div> </div>
) : null} ) : null}
{inStock ? ( {isPurchasable ? (
<> <>
<AddToCartWithQuantity product={product} maxQuantity={stock} className="mt-8 max-w-xs" /> <AddToCartWithQuantity product={product} maxQuantity={stock} className="mt-8 max-w-xs" />
{lowStock ? ( {lowStock ? (
@@ -221,33 +230,41 @@ export default async function ProductDetailPage(
<p className="mt-2 text-xs text-ink/65"> <p className="mt-2 text-xs text-ink/65">
{t.productPage.currentlyUnavailable} {t.productPage.currentlyUnavailable}
</p> </p>
<form action={subscribeStockNotificationAction} className="mt-4 max-w-xs"> {/* Nur bei aktivem, aber ausverkauftem Produkt: die
<input type="hidden" name="productId" value={product.id} /> Benachrichtigung haengt am Lagerbestand (lib/stock-
<label htmlFor="notify-email" className="text-xs text-ink/65"> notifications.ts). Bei einem dauerhaft deaktivierten Produkt
{t.productPage.notifyMeLabel} waere sie ein Versprechen, das nie eingeloest wird. */}
</label> {product.isActive ? (
<div className="mt-2 flex gap-2"> <>
<input <form action={subscribeStockNotificationAction} className="mt-4 max-w-xs">
id="notify-email" <input type="hidden" name="productId" value={product.id} />
name="email" <label htmlFor="notify-email" className="text-xs text-ink/65">
type="email" {t.productPage.notifyMeLabel}
required </label>
placeholder={t.productPage.emailPlaceholder} <div className="mt-2 flex gap-2">
className="flex-1 min-w-0 border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest" <input
/> id="notify-email"
<button name="email"
type="submit" type="email"
className="shrink-0 border border-ink/20 px-3 py-2 text-sm hover:border-forest" required
> placeholder={t.productPage.emailPlaceholder}
{t.productPage.notifySubmit} className="flex-1 min-w-0 border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
</button> />
</div> <button
</form> type="submit"
{notifyError ? ( className="shrink-0 border border-ink/20 px-3 py-2 text-sm hover:border-forest"
<p className="mt-2 text-xs text-red-700">{notifyError}</p> >
) : null} {t.productPage.notifySubmit}
{notifyMessage ? ( </button>
<p className="mt-2 text-xs text-forest">{notifyMessage}</p> </div>
</form>
{notifyError ? (
<p className="mt-2 text-xs text-red-700">{notifyError}</p>
) : null}
{notifyMessage ? (
<p className="mt-2 text-xs text-forest">{notifyMessage}</p>
) : null}
</>
) : null} ) : null}
</> </>
)} )}