Warenkorb/Produktseite: nie mehr in den Warenkorb legen können als auf Lager ist

- Produktseite: Mengen-Stepper und manuelle Eingabe sind jetzt auf den
  tatsächlichen Lagerbestand begrenzt (zählt bereits im Warenkorb
  liegende Menge desselben Produkts mit). Ist das Maximum schon
  erreicht, wird der "In den Warenkorb"-Button deaktiviert und zeigt
  "Maximale verfügbare Menge ist bereits im Warenkorb"; bei wenig Rest-
  bestand (≤5) erscheint stattdessen "Nur noch X auf Lager".
- Warenkorb-Seite: der "+"-Button bei der Mengenauswahl ist ebenfalls auf
  den Lagerbestand begrenzt.
- Als zusätzliche Absicherung greift das Limit jetzt auch direkt in
  addToCart()/updateQuantity() selbst (cart.ts) — unabhängig davon,
  welche Oberfläche die Funktion aufruft, kann so nie mehr Menge als
  Bestand ins localStorage gelangen.

Verifiziert per Puppeteer an einem Produkt mit Bestand 2: 6× auf "+"
klicken bleibt bei 2, manuelles Eintippen von 999 wird auf 2 geklemmt,
nach dem Hinzufügen ist der Button deaktiviert, der "+"-Button im
Warenkorb erhöht nicht weiter — keine Konsolenfehler.
This commit is contained in:
qcigano
2026-08-02 20:42:21 +02:00
parent 7e8e85851c
commit 51ac0affa7
10 changed files with 288 additions and 22 deletions
+62 -4
View File
@@ -89,7 +89,7 @@ const felder: [string, string | undefined][] = [
<label for="qty">{t.common.quantity}</label>
<div class="qty-stepper">
<button type="button" id="qty-dec" aria-label=""></button>
<input type="number" id="qty" min="1" value="1" inputmode="numeric" />
<input type="number" id="qty" min="1" max={product.bestand} value={product.bestand > 0 ? 1 : 0} inputmode="numeric" data-bestand={product.bestand} />
<button type="button" id="qty-inc" aria-label="+">+</button>
</div>
<button
@@ -99,10 +99,12 @@ const felder: [string, string | undefined][] = [
data-slug={product.slug}
data-name={name}
data-preis={finalPrice}
data-bestand={product.bestand}
>
{product.bestand === 0 ? t.common.soldOut : t.common.addToCart}
</button>
</div>
<p class="small" id="stock-hint" style="display:none;"></p>
<p class="small" id="add-confirm" role="status" style="display:none; color: var(--c-accent);">{t.common.addedToCart}</p>
{felder.some(([, v]) => v) && (
@@ -131,22 +133,78 @@ const felder: [string, string | undefined][] = [
)}
</Layout>
<script define:vars={{ onlyLeftTemplate: t.common.onlyXLeft("__N__"), maxInCartText: t.common.maxInCart, soldOutText: t.common.soldOut, addToCartText: t.common.addToCart }}>
window.__productStockVars = { onlyLeftTemplate, maxInCartText, soldOutText, addToCartText };
</script>
<script>
import { addToCart } from "../../../scripts/cart";
import { addToCart, getCart } from "../../../scripts/cart";
const { onlyLeftTemplate, maxInCartText, soldOutText, addToCartText } = (window as any).__productStockVars;
const btn = document.getElementById("add-to-cart") as HTMLButtonElement | null;
const qtyInput = document.getElementById("qty") as HTMLInputElement;
const confirm = document.getElementById("add-confirm")!;
const stockHint = document.getElementById("stock-hint")!;
const bestand = Number(qtyInput.dataset.bestand || 0);
const slug = btn?.dataset.slug;
// Nie mehr in den Warenkorb legen können, als tatsächlich auf Lager ist — zählt dabei mit,
// was von diesem Produkt schon im Warenkorb liegt (nicht nur die aktuelle Eingabe hier).
function bereitsImWarenkorb() {
return getCart().find((i) => i.slug === slug)?.menge ?? 0;
}
function maxJetztHinzufuegbar() {
return Math.max(0, bestand - bereitsImWarenkorb());
}
function clamp() {
const max = maxJetztHinzufuegbar();
let val = Math.max(0, Math.floor(Number(qtyInput.value) || 0));
if (val > max) val = max;
if (val < 1 && max > 0) val = 1;
qtyInput.value = String(val);
qtyInput.max = String(max);
if (btn) {
if (bestand === 0) {
btn.disabled = true;
btn.textContent = soldOutText;
} else if (max === 0) {
btn.disabled = true;
btn.textContent = maxInCartText;
stockHint.style.display = "block";
stockHint.textContent = maxInCartText;
} else {
btn.disabled = false;
btn.textContent = addToCartText;
if (max <= 5) {
stockHint.style.display = "block";
stockHint.textContent = onlyLeftTemplate.replace("__N__", String(max));
} else {
stockHint.style.display = "none";
}
}
}
return val;
}
clamp();
document.getElementById("qty-dec")?.addEventListener("click", () => {
qtyInput.value = String(Math.max(1, Number(qtyInput.value) - 1));
clamp();
});
document.getElementById("qty-inc")?.addEventListener("click", () => {
qtyInput.value = String(Number(qtyInput.value) + 1);
clamp();
});
qtyInput.addEventListener("input", clamp);
qtyInput.addEventListener("change", clamp);
btn?.addEventListener("click", () => {
const { slug, name, preis } = btn.dataset;
addToCart({ slug: slug!, name: name!, preis: Number(preis) }, Math.max(1, Number(qtyInput.value) || 1));
const menge = clamp();
if (menge < 1) return;
const { name, preis } = btn.dataset;
addToCart({ slug: slug!, name: name!, preis: Number(preis) }, menge);
confirm.style.display = "block";
setTimeout(() => (confirm.style.display = "none"), 2500);
clamp();
});
// Klick auf ein Miniaturbild zeigt es groß im Hauptbild an (mehrere Produktfotos).
+6 -1
View File
@@ -166,7 +166,12 @@ import { effektiverZeilenpreis, mengenrabattProzent } from "../../../data/rabatt
const action = target.dataset.action;
const item = getCart().find((i) => i.slug === slug);
if (!item) return;
if (action === "inc") updateQuantity(slug, item.menge + 1);
// Nie mehr in den Warenkorb legen können, als tatsächlich auf Lager ist.
if (action === "inc") {
const produkt = getProduct(slug);
const max = produkt ? produkt.bestand : Infinity;
updateQuantity(slug, Math.min(item.menge + 1, max));
}
if (action === "dec") updateQuantity(slug, item.menge - 1);
if (action === "remove") removeFromCart(slug);
render();
+62 -4
View File
@@ -89,7 +89,7 @@ const felder: [string, string | undefined][] = [
<label for="qty">{t.common.quantity}</label>
<div class="qty-stepper">
<button type="button" id="qty-dec" aria-label=""></button>
<input type="number" id="qty" min="1" value="1" inputmode="numeric" />
<input type="number" id="qty" min="1" max={product.bestand} value={product.bestand > 0 ? 1 : 0} inputmode="numeric" data-bestand={product.bestand} />
<button type="button" id="qty-inc" aria-label="+">+</button>
</div>
<button
@@ -99,10 +99,12 @@ const felder: [string, string | undefined][] = [
data-slug={product.slug}
data-name={name}
data-preis={finalPrice}
data-bestand={product.bestand}
>
{product.bestand === 0 ? t.common.soldOut : t.common.addToCart}
</button>
</div>
<p class="small" id="stock-hint" style="display:none;"></p>
<p class="small" id="add-confirm" role="status" style="display:none; color: var(--c-accent);">{t.common.addedToCart}</p>
{felder.some(([, v]) => v) && (
@@ -131,22 +133,78 @@ const felder: [string, string | undefined][] = [
)}
</Layout>
<script define:vars={{ onlyLeftTemplate: t.common.onlyXLeft("__N__"), maxInCartText: t.common.maxInCart, soldOutText: t.common.soldOut, addToCartText: t.common.addToCart }}>
window.__productStockVars = { onlyLeftTemplate, maxInCartText, soldOutText, addToCartText };
</script>
<script>
import { addToCart } from "../../../scripts/cart";
import { addToCart, getCart } from "../../../scripts/cart";
const { onlyLeftTemplate, maxInCartText, soldOutText, addToCartText } = (window as any).__productStockVars;
const btn = document.getElementById("add-to-cart") as HTMLButtonElement | null;
const qtyInput = document.getElementById("qty") as HTMLInputElement;
const confirm = document.getElementById("add-confirm")!;
const stockHint = document.getElementById("stock-hint")!;
const bestand = Number(qtyInput.dataset.bestand || 0);
const slug = btn?.dataset.slug;
// Nie mehr in den Warenkorb legen können, als tatsächlich auf Lager ist — zählt dabei mit,
// was von diesem Produkt schon im Warenkorb liegt (nicht nur die aktuelle Eingabe hier).
function bereitsImWarenkorb() {
return getCart().find((i) => i.slug === slug)?.menge ?? 0;
}
function maxJetztHinzufuegbar() {
return Math.max(0, bestand - bereitsImWarenkorb());
}
function clamp() {
const max = maxJetztHinzufuegbar();
let val = Math.max(0, Math.floor(Number(qtyInput.value) || 0));
if (val > max) val = max;
if (val < 1 && max > 0) val = 1;
qtyInput.value = String(val);
qtyInput.max = String(max);
if (btn) {
if (bestand === 0) {
btn.disabled = true;
btn.textContent = soldOutText;
} else if (max === 0) {
btn.disabled = true;
btn.textContent = maxInCartText;
stockHint.style.display = "block";
stockHint.textContent = maxInCartText;
} else {
btn.disabled = false;
btn.textContent = addToCartText;
if (max <= 5) {
stockHint.style.display = "block";
stockHint.textContent = onlyLeftTemplate.replace("__N__", String(max));
} else {
stockHint.style.display = "none";
}
}
}
return val;
}
clamp();
document.getElementById("qty-dec")?.addEventListener("click", () => {
qtyInput.value = String(Math.max(1, Number(qtyInput.value) - 1));
clamp();
});
document.getElementById("qty-inc")?.addEventListener("click", () => {
qtyInput.value = String(Number(qtyInput.value) + 1);
clamp();
});
qtyInput.addEventListener("input", clamp);
qtyInput.addEventListener("change", clamp);
btn?.addEventListener("click", () => {
const { slug, name, preis } = btn.dataset;
addToCart({ slug: slug!, name: name!, preis: Number(preis) }, Math.max(1, Number(qtyInput.value) || 1));
const menge = clamp();
if (menge < 1) return;
const { name, preis } = btn.dataset;
addToCart({ slug: slug!, name: name!, preis: Number(preis) }, menge);
confirm.style.display = "block";
setTimeout(() => (confirm.style.display = "none"), 2500);
clamp();
});
// Klick auf ein Miniaturbild zeigt es groß im Hauptbild an (mehrere Produktfotos).
+6 -1
View File
@@ -166,7 +166,12 @@ import { effektiverZeilenpreis, mengenrabattProzent } from "../../../data/rabatt
const action = target.dataset.action;
const item = getCart().find((i) => i.slug === slug);
if (!item) return;
if (action === "inc") updateQuantity(slug, item.menge + 1);
// Nie mehr in den Warenkorb legen können, als tatsächlich auf Lager ist.
if (action === "inc") {
const produkt = getProduct(slug);
const max = produkt ? produkt.bestand : Infinity;
updateQuantity(slug, Math.min(item.menge + 1, max));
}
if (action === "dec") updateQuantity(slug, item.menge - 1);
if (action === "remove") removeFromCart(slug);
render();
+62 -4
View File
@@ -89,7 +89,7 @@ const felder: [string, string | undefined][] = [
<label for="qty">{t.common.quantity}</label>
<div class="qty-stepper">
<button type="button" id="qty-dec" aria-label=""></button>
<input type="number" id="qty" min="1" value="1" inputmode="numeric" />
<input type="number" id="qty" min="1" max={product.bestand} value={product.bestand > 0 ? 1 : 0} inputmode="numeric" data-bestand={product.bestand} />
<button type="button" id="qty-inc" aria-label="+">+</button>
</div>
<button
@@ -99,10 +99,12 @@ const felder: [string, string | undefined][] = [
data-slug={product.slug}
data-name={name}
data-preis={finalPrice}
data-bestand={product.bestand}
>
{product.bestand === 0 ? t.common.soldOut : t.common.addToCart}
</button>
</div>
<p class="small" id="stock-hint" style="display:none;"></p>
<p class="small" id="add-confirm" role="status" style="display:none; color: var(--c-accent);">{t.common.addedToCart}</p>
{felder.some(([, v]) => v) && (
@@ -131,22 +133,78 @@ const felder: [string, string | undefined][] = [
)}
</Layout>
<script define:vars={{ onlyLeftTemplate: t.common.onlyXLeft("__N__"), maxInCartText: t.common.maxInCart, soldOutText: t.common.soldOut, addToCartText: t.common.addToCart }}>
window.__productStockVars = { onlyLeftTemplate, maxInCartText, soldOutText, addToCartText };
</script>
<script>
import { addToCart } from "../../../scripts/cart";
import { addToCart, getCart } from "../../../scripts/cart";
const { onlyLeftTemplate, maxInCartText, soldOutText, addToCartText } = (window as any).__productStockVars;
const btn = document.getElementById("add-to-cart") as HTMLButtonElement | null;
const qtyInput = document.getElementById("qty") as HTMLInputElement;
const confirm = document.getElementById("add-confirm")!;
const stockHint = document.getElementById("stock-hint")!;
const bestand = Number(qtyInput.dataset.bestand || 0);
const slug = btn?.dataset.slug;
// Nie mehr in den Warenkorb legen können, als tatsächlich auf Lager ist — zählt dabei mit,
// was von diesem Produkt schon im Warenkorb liegt (nicht nur die aktuelle Eingabe hier).
function bereitsImWarenkorb() {
return getCart().find((i) => i.slug === slug)?.menge ?? 0;
}
function maxJetztHinzufuegbar() {
return Math.max(0, bestand - bereitsImWarenkorb());
}
function clamp() {
const max = maxJetztHinzufuegbar();
let val = Math.max(0, Math.floor(Number(qtyInput.value) || 0));
if (val > max) val = max;
if (val < 1 && max > 0) val = 1;
qtyInput.value = String(val);
qtyInput.max = String(max);
if (btn) {
if (bestand === 0) {
btn.disabled = true;
btn.textContent = soldOutText;
} else if (max === 0) {
btn.disabled = true;
btn.textContent = maxInCartText;
stockHint.style.display = "block";
stockHint.textContent = maxInCartText;
} else {
btn.disabled = false;
btn.textContent = addToCartText;
if (max <= 5) {
stockHint.style.display = "block";
stockHint.textContent = onlyLeftTemplate.replace("__N__", String(max));
} else {
stockHint.style.display = "none";
}
}
}
return val;
}
clamp();
document.getElementById("qty-dec")?.addEventListener("click", () => {
qtyInput.value = String(Math.max(1, Number(qtyInput.value) - 1));
clamp();
});
document.getElementById("qty-inc")?.addEventListener("click", () => {
qtyInput.value = String(Number(qtyInput.value) + 1);
clamp();
});
qtyInput.addEventListener("input", clamp);
qtyInput.addEventListener("change", clamp);
btn?.addEventListener("click", () => {
const { slug, name, preis } = btn.dataset;
addToCart({ slug: slug!, name: name!, preis: Number(preis) }, Math.max(1, Number(qtyInput.value) || 1));
const menge = clamp();
if (menge < 1) return;
const { name, preis } = btn.dataset;
addToCart({ slug: slug!, name: name!, preis: Number(preis) }, menge);
confirm.style.display = "block";
setTimeout(() => (confirm.style.display = "none"), 2500);
clamp();
});
// Klick auf ein Miniaturbild zeigt es groß im Hauptbild an (mehrere Produktfotos).
+6 -1
View File
@@ -166,7 +166,12 @@ import { effektiverZeilenpreis, mengenrabattProzent } from "../../../data/rabatt
const action = target.dataset.action;
const item = getCart().find((i) => i.slug === slug);
if (!item) return;
if (action === "inc") updateQuantity(slug, item.menge + 1);
// Nie mehr in den Warenkorb legen können, als tatsächlich auf Lager ist.
if (action === "inc") {
const produkt = getProduct(slug);
const max = produkt ? produkt.bestand : Infinity;
updateQuantity(slug, Math.min(item.menge + 1, max));
}
if (action === "dec") updateQuantity(slug, item.menge - 1);
if (action === "remove") removeFromCart(slug);
render();
+62 -4
View File
@@ -89,7 +89,7 @@ const felder: [string, string | undefined][] = [
<label for="qty">{t.common.quantity}</label>
<div class="qty-stepper">
<button type="button" id="qty-dec" aria-label=""></button>
<input type="number" id="qty" min="1" value="1" inputmode="numeric" />
<input type="number" id="qty" min="1" max={product.bestand} value={product.bestand > 0 ? 1 : 0} inputmode="numeric" data-bestand={product.bestand} />
<button type="button" id="qty-inc" aria-label="+">+</button>
</div>
<button
@@ -99,10 +99,12 @@ const felder: [string, string | undefined][] = [
data-slug={product.slug}
data-name={name}
data-preis={finalPrice}
data-bestand={product.bestand}
>
{product.bestand === 0 ? t.common.soldOut : t.common.addToCart}
</button>
</div>
<p class="small" id="stock-hint" style="display:none;"></p>
<p class="small" id="add-confirm" role="status" style="display:none; color: var(--c-accent);">{t.common.addedToCart}</p>
{felder.some(([, v]) => v) && (
@@ -131,22 +133,78 @@ const felder: [string, string | undefined][] = [
)}
</Layout>
<script define:vars={{ onlyLeftTemplate: t.common.onlyXLeft("__N__"), maxInCartText: t.common.maxInCart, soldOutText: t.common.soldOut, addToCartText: t.common.addToCart }}>
window.__productStockVars = { onlyLeftTemplate, maxInCartText, soldOutText, addToCartText };
</script>
<script>
import { addToCart } from "../../scripts/cart";
import { addToCart, getCart } from "../../scripts/cart";
const { onlyLeftTemplate, maxInCartText, soldOutText, addToCartText } = (window as any).__productStockVars;
const btn = document.getElementById("add-to-cart") as HTMLButtonElement | null;
const qtyInput = document.getElementById("qty") as HTMLInputElement;
const confirm = document.getElementById("add-confirm")!;
const stockHint = document.getElementById("stock-hint")!;
const bestand = Number(qtyInput.dataset.bestand || 0);
const slug = btn?.dataset.slug;
// Nie mehr in den Warenkorb legen können, als tatsächlich auf Lager ist — zählt dabei mit,
// was von diesem Produkt schon im Warenkorb liegt (nicht nur die aktuelle Eingabe hier).
function bereitsImWarenkorb() {
return getCart().find((i) => i.slug === slug)?.menge ?? 0;
}
function maxJetztHinzufuegbar() {
return Math.max(0, bestand - bereitsImWarenkorb());
}
function clamp() {
const max = maxJetztHinzufuegbar();
let val = Math.max(0, Math.floor(Number(qtyInput.value) || 0));
if (val > max) val = max;
if (val < 1 && max > 0) val = 1;
qtyInput.value = String(val);
qtyInput.max = String(max);
if (btn) {
if (bestand === 0) {
btn.disabled = true;
btn.textContent = soldOutText;
} else if (max === 0) {
btn.disabled = true;
btn.textContent = maxInCartText;
stockHint.style.display = "block";
stockHint.textContent = maxInCartText;
} else {
btn.disabled = false;
btn.textContent = addToCartText;
if (max <= 5) {
stockHint.style.display = "block";
stockHint.textContent = onlyLeftTemplate.replace("__N__", String(max));
} else {
stockHint.style.display = "none";
}
}
}
return val;
}
clamp();
document.getElementById("qty-dec")?.addEventListener("click", () => {
qtyInput.value = String(Math.max(1, Number(qtyInput.value) - 1));
clamp();
});
document.getElementById("qty-inc")?.addEventListener("click", () => {
qtyInput.value = String(Number(qtyInput.value) + 1);
clamp();
});
qtyInput.addEventListener("input", clamp);
qtyInput.addEventListener("change", clamp);
btn?.addEventListener("click", () => {
const { slug, name, preis } = btn.dataset;
addToCart({ slug: slug!, name: name!, preis: Number(preis) }, Math.max(1, Number(qtyInput.value) || 1));
const menge = clamp();
if (menge < 1) return;
const { name, preis } = btn.dataset;
addToCart({ slug: slug!, name: name!, preis: Number(preis) }, menge);
confirm.style.display = "block";
setTimeout(() => (confirm.style.display = "none"), 2500);
clamp();
});
// Klick auf ein Miniaturbild zeigt es groß im Hauptbild an (mehrere Produktfotos).
+6 -1
View File
@@ -166,7 +166,12 @@ import { effektiverZeilenpreis, mengenrabattProzent } from "../../data/rabatt";
const action = target.dataset.action;
const item = getCart().find((i) => i.slug === slug);
if (!item) return;
if (action === "inc") updateQuantity(slug, item.menge + 1);
// Nie mehr in den Warenkorb legen können, als tatsächlich auf Lager ist.
if (action === "inc") {
const produkt = getProduct(slug);
const max = produkt ? produkt.bestand : Infinity;
updateQuantity(slug, Math.min(item.menge + 1, max));
}
if (action === "dec") updateQuantity(slug, item.menge - 1);
if (action === "remove") removeFromCart(slug);
render();