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:
1 parent
746c12ae68
commit
91aee0d609
2 files changed
+67
-32
No files matched your search
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in new issue
Block a user