Files
vans-diy-bastelbedarf/src/pages/en/warenkorb/index.astro
T
qciganoandClaude Sonnet 5 208d995941 Abosystem mit dauerhaften Rabatten und Teddy-Prämien
Drei Mitgliedschaftsstufen (Klein 4,99€/5%, Mittel 9,99€/10%, Groß 19,99€/20%)
mit automatischem Bestellrabatt und Teddy-Prämien nach Meilensteinen (12-Monats-
Rhythmus bzw. 6+12 bei Groß). Preise/Rabatte und alle Admin-Regeln (Kombinier-
barkeit mit anderen Rabatten, Fortsetzung bei Wiederanmeldung, Monate über
Stufen zusammenzählen, kostenloser Versand bei Prämie) sind über Decap CMS
einstellbar (src/content/abo-einstellungen.json).

Neuer "Mein Abo"-Bereich im Kundenkonto (6. Nav-Kachel) mit Plan-Vergleich,
Live-Status (Laufzeit, nächste Zahlung, Rabatt, Fortschritt bis zur nächsten
Prämie), Einlösen-Buttons für freigeschaltete Teddys, Kündigen/Wechseln sowie
einer übersetzten Benachrichtigungsliste. Dazu ein extra hervorgehobener
"Mein Abo verwalten"-Button neben Abmelden in der Kopfkarte.

Warenkorb/Checkout rechnen den Abo-Rabatt korrekt in die Summe ein und stellen
eingelöste Teddys als 0€-Gratisposition dar. Alles in 4 Sprachen (DE/EN/CH/FR).

Unterwegs zwei echte Bugs gefunden und behoben: addMonateISO() vermischte
lokale Zeit mit toISOString() (UTC) und verlor dadurch bei jeder Monats-
verlängerung reproduzierbar einen Tag; die Checkout-Positionsliste zeigte für
Gratis-Prämien fälschlich den vollen Preis statt 0€.

Wie beim Treuebonus gilt: Speicherung aktuell im localStorage des Geräts
(Phase 1) statt einer echten geräteübergreifenden Datenbank — folgt mit der
Zahlungsanbindung in Phase 2.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-03 16:54:25 +02:00

297 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import Layout from "../../../layouts/Layout.astro";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
const lang: Locale = "en";
const t = useTranslations(lang);
---
<Layout title="Cart" description="Your cart at Van's DIY & Bastelbedarf." lang={lang} path="/warenkorb/">
<section class="section-tight">
<div class="container">
<span class="eyebrow">{t.cart.eyebrow}</span>
<h1>{t.cart.title}</h1>
</div>
</section>
<section class="section-tight">
<div class="container">
<div id="cart-empty" class="card" style="display:none;">
<p>{t.cart.empty}</p>
<a class="btn btn-primary" href="/en/shop/">{t.common.browseShop}</a>
</div>
{/* Treuebonus-Banner: nur sichtbar, wenn im Konto ein Bonus wartet (siehe scripts/account.ts)
— erklärt direkt hier, dass man unten bei einem Artikel "-20%" auswählen kann. */}
<div class="card loyalty-banner" id="loyalty-banner" style="display:none;">
<span class="loyalty-banner-icon" aria-hidden="true">🎁</span>
<div>
<strong>{t.cart.loyaltyBannerTitle}</strong>
<p>{t.cart.loyaltyBannerText}</p>
</div>
</div>
<div id="cart-content" class="cart-layout" style="display:none;">
<div class="card cart-items" id="cart-items"></div>
<aside class="card cart-summary">
<h3>🧾 {t.cart.subtotal}</h3>
<div id="summary-items" class="checkout-summary"></div>
<div class="summary-row"><span>{t.cart.subtotal}</span><span id="sum-subtotal">0,00 €</span></div>
<div class="summary-row discount-row" id="discount-row" style="display:none;"><span>{t.cart.discount}</span><span id="sum-discount">-0,00 €</span></div>
<div class="summary-row discount-row" id="partner-discount-row" style="display:none;"><span>{t.cart.partnerDiscount}</span><span id="sum-partner-discount">-0,00 €</span></div>
<div class="summary-row discount-row" id="loyalty-discount-row" style="display:none;"><span>{t.cart.loyaltyDiscount}</span><span id="sum-loyalty-discount">-0,00 €</span></div>
<div class="summary-row discount-row" id="abo-discount-row" style="display:none;"><span>{t.cart.aboDiscount}</span><span id="sum-abo-discount">-0,00 €</span></div>
<div class="coupon-row">
<label for="coupon-input">{t.cart.couponLabel}</label>
<div class="coupon-input-group">
<input type="text" id="coupon-input" placeholder={t.cart.couponPlaceholder} />
<button type="button" id="coupon-apply" class="btn btn-outline btn-sm">{t.cart.couponApply}</button>
</div>
<p class="small" id="coupon-status"></p>
</div>
<div class="coupon-row">
<label for="partner-code-input">{t.cart.partnerCodeLabel}</label>
<div class="coupon-input-group">
<input type="text" id="partner-code-input" placeholder={t.cart.partnerCodePlaceholder} />
<button type="button" id="partner-code-apply" class="btn btn-outline btn-sm">{t.cart.partnerCodeApply}</button>
</div>
<p class="small" id="partner-code-status"></p>
</div>
<div class="ship-country-row">
<label for="cart-land">📦 {t.checkout.country}</label>
<select id="cart-land">
<option value="de">Germany</option>
<option value="at">Austria</option>
<option value="ch">Switzerland</option>
<option value="lu">Luxembourg</option>
</select>
</div>
<div class="summary-row"><span>{t.cart.shipping}</span><span id="sum-shipping">{t.cart.shippingCalculated}</span></div>
<p class="free-shipping-badge" id="shipping-hint"></p>
<hr class="divider" />
<div class="summary-row total"><span>{t.cart.total}</span><span id="sum-total">0,00 €</span></div>
<p class="small">{t.common.vatNote}</p>
<a class="btn btn-primary btn-block" href="/en/checkout/">{t.cart.toCheckout}</a>
</aside>
</div>
</div>
</section>
</Layout>
<script define:vars={{ freeShipFrom: 75, removeLabel: t.cart.remove, perItemLabel: t.cart.perItem, remainingTemplate: t.cart.remaining("__V__"), freeShippingText: t.cart.freeShipping, freeLabel: t.checkout.free, cartLang: lang, mengenrabattTemplate: t.cart.mengenrabatt("__P__"), couponInvalid: t.cart.couponInvalid, couponAppliedTemplate: t.cart.couponApplied("__C__"), couponRemoveLabel: t.cart.couponRemove, buyNowLabel: t.cart.buyNow, keptInCartLabel: t.cart.keptInCart, partnerCodeInvalid: t.cart.partnerCodeInvalid, partnerCodeAppliedTemplate: t.cart.partnerCodeApplied("__C__"), partnerCodeRemoveLabel: t.cart.partnerCodeRemove, loyaltySelectLabel: t.cart.loyaltySelectLabel, loyaltySelectedLabel: t.cart.loyaltySelectedLabel, gratisLabel: t.cart.gratisPraemie }}>
// WICHTIG: define:vars-Skripte laufen als IIFE, keine "import"-Anweisungen möglich.
window.__cartVars = { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang, mengenrabattTemplate, couponInvalid, couponAppliedTemplate, couponRemoveLabel, buyNowLabel, keptInCartLabel, partnerCodeInvalid, partnerCodeAppliedTemplate, partnerCodeRemoveLabel, loyaltySelectLabel, loyaltySelectedLabel, gratisLabel };
</script>
<script type="module">
import { getCart, updateQuantity, removeFromCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon, appliedPartnerCode, partnerCodeDiscount, setPartnerCode, clearPartnerCode, totalDiscount, treuebonusDiscount, aboDiscount, istAusgewaehlt, setAusgewaehlt, zeilenpreisFuer } from "../../../scripts/cart";
import { formatPrice } from "../../../i18n/format";
import { getProduct, products } from "../../../data/products";
import { berechneVersandkosten } from "../../../data/versand";
import { mengenrabattProzent } from "../../../data/rabatt";
import { getAccount, treueFortschritt, setTreuebonusArtikel } from "../../../scripts/account";
const { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang, mengenrabattTemplate, couponInvalid, couponAppliedTemplate, couponRemoveLabel, buyNowLabel, keptInCartLabel, partnerCodeInvalid, partnerCodeAppliedTemplate, partnerCodeRemoveLabel, loyaltySelectLabel, loyaltySelectedLabel, gratisLabel } = window.__cartVars;
const landSelect = document.getElementById("cart-land");
function render() {
const cart = getCart();
const empty = document.getElementById("cart-empty");
const content = document.getElementById("cart-content");
const itemsEl = document.getElementById("cart-items");
if (cart.length === 0) {
empty.style.display = "block";
content.style.display = "none";
return;
}
empty.style.display = "none";
content.style.display = "grid";
const zeilen = cart.map((i) => {
const produkt = getProduct(i.slug);
const zeilenpreis = zeilenpreisFuer(i);
const mengenProzent = produkt ? mengenrabattProzent(produkt, i.menge) : 0;
return { item: i, produkt, zeilenpreis, mengenProzent };
});
// Treuebonus: nur relevant, wenn im Konto gerade eine Belohnung auf Einlösung wartet — dann
// darf hier EIN Artikel frei ausgewählt werden, auf den die -20% angewendet werden.
const konto = getAccount();
const treueAktiv = treueFortschritt(konto).aktiv;
const gewaehlterSlug = konto.treuebonusArtikelSlug;
const loyaltyBanner = document.getElementById("loyalty-banner");
if (loyaltyBanner) loyaltyBanner.style.display = treueAktiv ? "flex" : "none";
itemsEl.innerHTML = zeilen.map(({ item: i, produkt, zeilenpreis, mengenProzent }) => {
const foto = produkt?.bilder?.[0];
const thumb = foto
? `<img class="thumb product-photo" src="${foto}" alt="${i.name}" loading="lazy" />`
: `<div class="img-placeholder thumb" role="img" aria-label="${i.name}"></div>`;
const mengenBadge = mengenProzent > 0 ? `<div class="small mengenrabatt-badge">${mengenrabattTemplate.replace("__P__", mengenProzent)}</div>` : "";
const ausgewaehlt = istAusgewaehlt(i);
const keptBadge = !ausgewaehlt ? `<div class="small kept-in-cart-badge">🕓 ${keptInCartLabel}</div>` : "";
const istTreueartikel = gewaehlterSlug === i.slug;
const loyaltyControl = !treueAktiv ? "" : istTreueartikel
? `<span class="loyalty-selected-badge">${loyaltySelectedLabel}</span>`
: `<label class="loyalty-select"><input type="radio" name="loyalty-artikel" data-loyalty-slug="${i.slug}" /> ${loyaltySelectLabel}</label>`;
const gratisBadge = i.gratis ? `<div class="small loyalty-selected-badge">🧸 ${gratisLabel}</div>` : "";
return `
<div class="cart-item ${ausgewaehlt ? "" : "deselected"} ${istTreueartikel ? "loyalty-selected" : ""}">
<input type="checkbox" class="buy-toggle" data-slug="${i.slug}" ${ausgewaehlt ? "checked" : ""} aria-label="${buyNowLabel}" title="${buyNowLabel}" />
${thumb}
<div class="info">
<strong>${i.name}</strong>
${!i.gratis ? `<div class="small">${formatPrice(zeilenpreis / i.menge, cartLang)} ${perItemLabel}</div>` : ""}
${mengenBadge}
${keptBadge}
${gratisBadge}
${loyaltyControl}
</div>
<div class="qty-controls">
<button data-action="dec" data-slug="${i.slug}" aria-label=""></button>
<span>${i.menge}</span>
<button data-action="inc" data-slug="${i.slug}" aria-label="+">+</button>
</div>
<div class="line-total">${formatPrice(zeilenpreis, cartLang)}</div>
<a href="#" class="remove" data-action="remove" data-slug="${i.slug}">✕ ${removeLabel}</a>
</div>
`;
}).join("");
itemsEl.querySelectorAll("[data-loyalty-slug]").forEach((el) => {
el.addEventListener("change", (e) => {
setTreuebonusArtikel(e.currentTarget.dataset.loyaltySlug);
render();
});
});
// Nur ausgewählte Artikel fließen in Rechnung/Zwischensumme/Versand/Checkout ein — abgewählte
// bleiben sichtbar im Warenkorb liegen (leicht abgedunkelt), ohne mitgekauft zu werden.
const ausgewaehlteZeilen = zeilen.filter(({ item: i }) => istAusgewaehlt(i));
// Rechnungs-Übersicht in der Zusammenfassung — dieselbe Positionsliste wie im Warenkorb
// links, nur kompakt als "Beleg" (Menge × Name → Zeilenpreis), passend zum Checkout.
document.getElementById("summary-items").innerHTML = ausgewaehlteZeilen.map(({ item: i, zeilenpreis }) =>
`<div class="row"><span>${i.menge}× ${i.name}</span><span>${formatPrice(zeilenpreis, cartLang)}</span></div>`
).join("");
const subtotal = cartTotal();
const remaining = Math.max(0, freeShipFrom - subtotal);
const shipping = berechneVersandkosten(landSelect.value, ausgewaehlteZeilen.map(({ item: i }) => ({ slug: i.slug, preis: i.preis, menge: i.menge })), products);
const coupon = appliedCoupon();
const discount = couponDiscount(subtotal);
const partnerCode = appliedPartnerCode();
const partnerDiscount = partnerCodeDiscount(subtotal);
const total = Math.max(0, subtotal - totalDiscount(subtotal)) + shipping;
document.getElementById("sum-subtotal").textContent = formatPrice(subtotal, cartLang);
document.getElementById("sum-shipping").textContent = shipping === 0 ? freeLabel : formatPrice(shipping, cartLang);
document.getElementById("sum-total").textContent = formatPrice(total, cartLang);
document.getElementById("shipping-hint").textContent =
remaining > 0 ? remainingTemplate.replace("__V__", formatPrice(remaining, cartLang)) : `🩵 ${freeShippingText}`;
const discountRow = document.getElementById("discount-row");
const couponStatus = document.getElementById("coupon-status");
const couponInput = document.getElementById("coupon-input");
if (coupon && discount > 0) {
discountRow.style.display = "flex";
document.getElementById("sum-discount").textContent = "-" + formatPrice(discount, cartLang);
couponStatus.innerHTML = `✅ ${couponAppliedTemplate.replace("__C__", coupon.code)} <a href="#" id="coupon-clear">✕ ${couponRemoveLabel}</a>`;
couponInput.value = coupon.code;
const clearLink = document.getElementById("coupon-clear");
if (clearLink) clearLink.addEventListener("click", (e) => { e.preventDefault(); clearCoupon(); render(); });
} else {
discountRow.style.display = "none";
couponStatus.textContent = couponInput.dataset.invalid === "1" ? couponInvalid : "";
}
const partnerDiscountRow = document.getElementById("partner-discount-row");
const partnerStatus = document.getElementById("partner-code-status");
const partnerInput = document.getElementById("partner-code-input");
if (partnerCode && partnerDiscount > 0) {
partnerDiscountRow.style.display = "flex";
document.getElementById("sum-partner-discount").textContent = "-" + formatPrice(partnerDiscount, cartLang);
partnerStatus.innerHTML = `✅ ${partnerCodeAppliedTemplate.replace("__C__", partnerCode.code)} <a href="#" id="partner-code-clear">✕ ${partnerCodeRemoveLabel}</a>`;
partnerInput.value = partnerCode.code;
const partnerClearLink = document.getElementById("partner-code-clear");
if (partnerClearLink) partnerClearLink.addEventListener("click", (e) => { e.preventDefault(); clearPartnerCode(); render(); });
} else {
partnerDiscountRow.style.display = "none";
partnerStatus.textContent = partnerInput.dataset.invalid === "1" ? partnerCodeInvalid : "";
}
const loyaltyDiscountRow = document.getElementById("loyalty-discount-row");
const loyaltyDiscount = treuebonusDiscount();
if (loyaltyDiscountRow) {
if (loyaltyDiscount > 0) {
loyaltyDiscountRow.style.display = "flex";
document.getElementById("sum-loyalty-discount").textContent = "-" + formatPrice(loyaltyDiscount, cartLang);
} else {
loyaltyDiscountRow.style.display = "none";
}
}
const aboDiscountRow = document.getElementById("abo-discount-row");
const aboDiscountBetrag = aboDiscount(subtotal);
if (aboDiscountRow) {
if (aboDiscountBetrag > 0) {
aboDiscountRow.style.display = "flex";
document.getElementById("sum-abo-discount").textContent = "-" + formatPrice(aboDiscountBetrag, cartLang);
} else {
aboDiscountRow.style.display = "none";
}
}
itemsEl.querySelectorAll("button, a.remove").forEach((el) => {
el.addEventListener("click", (e) => {
e.preventDefault();
const target = e.currentTarget;
const slug = target.dataset.slug;
const action = target.dataset.action;
const item = getCart().find((i) => i.slug === slug);
if (!item) return;
// 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();
});
});
itemsEl.querySelectorAll(".buy-toggle").forEach((el) => {
el.addEventListener("change", (e) => {
setAusgewaehlt(e.currentTarget.dataset.slug, e.currentTarget.checked);
render();
});
});
}
document.getElementById("coupon-apply").addEventListener("click", () => {
const input = document.getElementById("coupon-input");
const code = input.value.trim();
if (!code) { clearCoupon(); render(); return; }
setCouponCode(code);
input.dataset.invalid = appliedCoupon() ? "0" : "1";
render();
});
document.getElementById("partner-code-apply").addEventListener("click", () => {
const input = document.getElementById("partner-code-input");
const code = input.value.trim();
if (!code) { clearPartnerCode(); render(); return; }
setPartnerCode(code);
input.dataset.invalid = appliedPartnerCode() ? "0" : "1";
render();
});
render();
window.addEventListener("cart:changed", render);
// Treuebonus-Auswahl liegt im Konto, nicht im Warenkorb — eigenes Event, damit die Anzeige
// auch reagiert, wenn sich NUR das Konto ändert (z.B. Bonus wird woanders freigeschaltet).
window.addEventListener("account:changed", render);
landSelect.addEventListener("change", render);
</script>