Rabatt-System, größerer Mengen-Stepper, Zahlungsart zuerst, echte HasiDog-Inhalte

- Neues Rabatt-System: Prozent-/Betrag-Rabatt mit optionalem Ablaufdatum,
  Mengenrabatt-Staffel pro Produkt, sowie site-weite Gutscheincodes.
  Wird live im Warenkorb/Checkout berechnet (src/data/rabatt.ts,
  src/data/gutscheine.ts, cart.ts) und ist im Adminbereich direkt unter dem
  Preis bearbeitbar (config.yml: "Rabatte" + neue Collection
  "Gutscheincodes"). Effektiver Preis + Rabatt-Badge auf Produktkarten und
  Produktseite sichtbar.
- Mengen-Stepper auf der Produktseite vergrößert (bereits vorbereitet,
  jetzt mit Build/Test verifiziert).
- Zahlungsart im Checkout auf Platz 1, mit markenfarbigen, fertig
  gestalteten Buttons (PayPal/Klarna/Banküberweisung).
- Unterkategorie-Seiten zeigen jetzt VanVans eigene Produktbeschreibung
  und das echte Fotos des Produkts statt Platzhaltertext, mit kleinem
  Link zur HasiDog-Universum-Seite statt großem Button.

Verifiziert per Puppeteer-Funktionstest gegen den echten Production-Build
(Rabatt-Berechnung, Mengenrabatt, Gutschein-Einlösung, Checkout-Summary,
keine Konsolenfehler).
This commit is contained in:
qcigano
2026-08-02 18:59:11 +02:00
parent 0f93ab73c3
commit 6f850181f1
25 changed files with 1098 additions and 194 deletions
+63 -25
View File
@@ -18,6 +18,26 @@ const t = useTranslations(lang);
<section class="section-tight">
<div class="container checkout-layout">
<form class="frm card" id="checkout-form">
<h3>{t.checkout.step2}</h3>
<div class="payment-options">
<label class="pay-option" style="--brand-color:#009cde; --brand-bg:#003087;">
<span class="pay-icon" style="background:#003087;">P</span>
<span>PayPal</span>
<input type="radio" name="pay" value="paypal" checked />
</label>
<label class="pay-option" style="--brand-color:#ffb3c7; --brand-bg:#17120f;">
<span class="pay-icon" style="background:#17120f; color:#ffb3c7;">K</span>
<span>Klarna</span>
<input type="radio" name="pay" value="klarna" />
</label>
<label class="pay-option" style="--brand-color:#7fa8c9; --brand-bg:#3b5a76;">
<span class="pay-icon" style="background:#3b5a76;">🏦</span>
<span>{t.checkout.bankTransfer}</span>
<input type="radio" name="pay" value="ueberweisung" />
</label>
</div>
<hr class="divider" />
<h3>{t.checkout.step1}</h3>
<div><label for="email">{t.checkout.email}</label><input id="email" type="email" required /></div>
<div class="grid grid-2">
@@ -39,26 +59,6 @@ const t = useTranslations(lang);
</select>
</div>
<hr class="divider" />
<h3>{t.checkout.step2}</h3>
<div class="payment-options">
<label class="pay-option">
<span class="pay-icon" aria-hidden="true">🅿️</span>
<span>PayPal</span>
<input type="radio" name="pay" value="paypal" checked />
</label>
<label class="pay-option">
<span class="pay-icon" aria-hidden="true">💳</span>
<span>Klarna</span>
<input type="radio" name="pay" value="klarna" />
</label>
<label class="pay-option">
<span class="pay-icon" aria-hidden="true">🏦</span>
<span>{t.checkout.bankTransfer}</span>
<input type="radio" name="pay" value="ueberweisung" />
</label>
</div>
<hr class="divider" />
<h3>{t.checkout.step3}</h3>
<div id="checkout-summary" class="checkout-summary"></div>
@@ -77,6 +77,15 @@ const t = useTranslations(lang);
</form>
<aside class="card checkout-aside">
<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>
<hr class="divider" />
<h3>{t.checkout.shippingHintTitle}</h3>
<p class="small">{t.checkout.shippingHint}</p>
<a class="small" href="/ch/versand-zahlung/">{t.checkout.shippingLink}</a>
@@ -87,15 +96,16 @@ const t = useTranslations(lang);
<script define:vars={{ freeLabel: t.checkout.free, totalLabel: t.checkout.total, shippingLabel: t.cart.shipping, emptyCartAlert: t.checkout.emptyCartAlert, thankYouPath: "/ch/checkout/danke/", checkoutLang: lang }}>
// WICHTIG: define:vars-Skripte laufen als IIFE, keine "import"-Anweisungen möglich.
window.__checkoutVars = { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang };
window.__checkoutVars = { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang, discountLabel, couponInvalid, couponAppliedTemplate, couponRemoveLabel };
</script>
<script type="module">
import { getCart, cartTotal } from "../../../scripts/cart";
import { getCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon } from "../../../scripts/cart";
import { formatPrice } from "../../../i18n/format";
import { products } from "../../../data/products";
import { berechneVersandkosten } from "../../../data/versand";
import { effektiverZeilenpreis } from "../../../data/rabatt";
const { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang } = window.__checkoutVars;
const { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang, discountLabel, couponInvalid, couponAppliedTemplate, couponRemoveLabel } = window.__checkoutVars;
const summary = document.getElementById("checkout-summary");
const landSelect = document.getElementById("land");
@@ -105,17 +115,45 @@ const t = useTranslations(lang);
function renderSummary() {
const land = landSelect.value;
const shipping = cart.length === 0 ? 0 : berechneVersandkosten(land, cart.map((i) => ({ slug: i.slug, preis: i.preis, menge: i.menge })), products);
const coupon = appliedCoupon();
const discount = couponDiscount(subtotal);
const total = Math.max(0, subtotal - discount) + shipping;
summary.innerHTML = `
${cart.map((i) => `<div class="row"><span>${i.menge}× ${i.name}</span><span>${formatPrice(i.preis * i.menge, checkoutLang)}</span></div>`).join("")}
${cart.map((i) => {
const produkt = products.find((p) => p.slug === i.slug);
const zeilenpreis = produkt ? effektiverZeilenpreis(produkt, i.menge) : i.menge * i.preis;
return `<div class="row"><span>${i.menge}× ${i.name}</span><span>${formatPrice(zeilenpreis, checkoutLang)}</span></div>`;
}).join("")}
${coupon && discount > 0 ? `<div class="row"><span>${discountLabel}</span><span>-${formatPrice(discount, checkoutLang)}</span></div>` : ""}
<div class="row"><span>${shippingLabel}</span><span>${shipping === 0 ? freeLabel : formatPrice(shipping, checkoutLang)}</span></div>
<div class="row total"><span>${totalLabel}</span><span>${formatPrice(subtotal + shipping, checkoutLang)}</span></div>
<div class="row total"><span>${totalLabel}</span><span>${formatPrice(total, checkoutLang)}</span></div>
`;
const couponStatus = document.getElementById("coupon-status");
const couponInput = document.getElementById("coupon-input");
if (coupon && discount > 0) {
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(); renderSummary(); });
} else {
couponStatus.textContent = couponInput.dataset.invalid === "1" ? couponInvalid : "";
}
}
renderSummary();
landSelect.addEventListener("change", renderSummary);
document.getElementById("coupon-apply").addEventListener("click", () => {
const input = document.getElementById("coupon-input");
const code = input.value.trim();
if (!code) { clearCoupon(); renderSummary(); return; }
setCouponCode(code);
input.dataset.invalid = appliedCoupon() ? "0" : "1";
renderSummary();
});
document.getElementById("checkout-form").addEventListener("submit", (e) => {
e.preventDefault();
if (cart.length === 0) {
+25 -3
View File
@@ -6,6 +6,7 @@ import { getCategory } from "../../../data/categories";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
import { formatPrice } from "../../../i18n/format";
import { effektiverPreis, rabattLabel, hatAktivenRabatt } from "../../../data/rabatt";
export function getStaticPaths() {
return products.map((p) => ({ params: { slug: p.slug } }));
@@ -22,6 +23,10 @@ const name = product.name[lang];
const desc = product.beschreibung[lang];
const badgeLabel: Record<string, string> = { neu: t.badge.neu, bestseller: t.badge.bestseller, sale: t.badge.sale, handgemacht: t.badge.handgemacht };
const finalPrice = effektiverPreis(product);
const discountBadge = rabattLabel(product);
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
const mengenrabatt = product.rabatt?.mengenrabatt ?? [];
const felder: [string, string | undefined][] = [
[t.product.articleNo, product.artikelnummer],
@@ -68,21 +73,32 @@ const felder: [string, string | undefined][] = [
<h1>{name}</h1>
<div class="price-row-detail">
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
<span class="price-detail">{formatPrice(product.preis, lang)}</span>
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
<span class="price-detail">{formatPrice(finalPrice, lang)}</span>
{discountBadge && <span class="badge badge-rabatt">{discountBadge}</span>}
</div>
{mengenrabatt.length > 0 && (
<ul class="mengenrabatt-list small">
{mengenrabatt.map((stufe) => <li>{t.cart.mengenrabattTier(String(stufe.abMenge), String(stufe.prozent))}</li>)}
</ul>
)}
<p class="small">{t.common.vatNote} · {t.common.plusShipping}</p>
<p class="lead">{desc}</p>
<div class="qty-row">
<label for="qty">{t.common.quantity}</label>
<input type="number" id="qty" min="1" value="1" />
<div class="qty-stepper">
<button type="button" id="qty-dec" aria-label=""></button>
<input type="number" id="qty" min="1" value="1" inputmode="numeric" />
<button type="button" id="qty-inc" aria-label="+">+</button>
</div>
<button
class="btn btn-primary"
id="add-to-cart"
disabled={product.bestand === 0}
data-slug={product.slug}
data-name={name}
data-preis={product.preis}
data-preis={finalPrice}
>
{product.bestand === 0 ? t.common.soldOut : t.common.addToCart}
</button>
@@ -120,6 +136,12 @@ const felder: [string, string | undefined][] = [
const btn = document.getElementById("add-to-cart") as HTMLButtonElement | null;
const qtyInput = document.getElementById("qty") as HTMLInputElement;
const confirm = document.getElementById("add-confirm")!;
document.getElementById("qty-dec")?.addEventListener("click", () => {
qtyInput.value = String(Math.max(1, Number(qtyInput.value) - 1));
});
document.getElementById("qty-inc")?.addEventListener("click", () => {
qtyInput.value = String(Number(qtyInput.value) + 1);
});
btn?.addEventListener("click", () => {
const { slug, name, preis } = btn.dataset;
addToCart({ slug: slug!, name: name!, preis: Number(preis) }, Math.max(1, Number(qtyInput.value) || 1));
@@ -1,6 +1,8 @@
---
import Layout from "../../../../layouts/Layout.astro";
import ProductCard from "../../../../components/ProductCard.astro";
import { categories, getCategory, getSubcategory } from "../../../../data/categories";
import { productsByUnterkategorie } from "../../../../data/products";
import type { Locale } from "../../../../i18n/config";
import { useTranslations } from "../../../../i18n/ui";
@@ -15,8 +17,12 @@ const t = useTranslations(lang);
const { kategorie, unterkategorie } = Astro.params;
const category = getCategory(kategorie!)!;
const sub = getSubcategory(kategorie!, unterkategorie!)!;
const echtProdukte = productsByUnterkategorie(category.slug, sub.slug);
const featured = echtProdukte[0];
const weitere = echtProdukte.slice(1);
---
<Layout title={`${sub.name[lang]} ${category.name[lang]}`} description={`${sub.name[lang]} bei Van's DIY & Bastelbedarf.`} lang={lang} path={`/shop/${category.slug}/${sub.slug}/`}>
<Layout title={`${sub.name[lang]} ${category.name[lang]}`} description={`${sub.name[lang]} bi Van's DIY & Bastelbedarf.`} lang={lang} path={`/shop/${category.slug}/${sub.slug}/`}>
<div class="container">
<p class="small breadcrumb" style="margin-top:1.5rem;">
<a href="/ch/shop/">{t.shop.breadcrumbShop}</a> / <a href={`/ch/shop/${category.slug}/`}>{category.name[lang]}</a> / {sub.name[lang]}
@@ -29,11 +35,23 @@ const sub = getSubcategory(kategorie!, unterkategorie!)!;
</section>
</div>
<section class="section-tight">
<div class="container">
{sub.vorstellung && (
<div class="character-intro">
{sub.vorstellung[lang].split("\n").map((line) => <p>{line}</p>)}
{featured ? (
<section class="section-tight">
<div class="container grid grid-2 intro">
<div class="portrait-frame portrait">
{featured.bilder && featured.bilder.length > 0 ? (
<img class="product-photo" src={featured.bilder[0]} alt={featured.name[lang]} loading="lazy" />
) : (
<div class="img-placeholder" role="img" aria-label={featured.name[lang]}></div>
)}
</div>
<div>
{sub.vorstellung && <span class="eyebrow">{sub.vorstellung[lang].split("\n")[0]}</span>}
<h2>{featured.name[lang]}</h2>
<p class="lead">{featured.beschreibung[lang]}</p>
<div class="btn-row">
<a class="btn btn-primary" href={`/ch/produkt/${featured.slug}/`}>{t.common.discoverNow} →</a>
</div>
{sub.mehrLink && (
<p class="character-more">
Wosch meh über {sub.name[lang]} wüsse? Klick da:{" "}
@@ -41,8 +59,37 @@ const sub = getSubcategory(kategorie!, unterkategorie!)!;
</p>
)}
</div>
)}
<p class="small">{t.shop.subcategoryEmpty}</p>
</div>
</section>
) : (
<section class="section-tight">
<div class="container">
{sub.vorstellung && (
<div class="character-intro">
{sub.vorstellung[lang].split("\n").map((line) => <p>{line}</p>)}
{sub.mehrLink && (
<p class="character-more">
Wosch meh über {sub.name[lang]} wüsse? Klick da:{" "}
<a class="character-more-link" href={sub.mehrLink} target="_blank" rel="noopener">da →</a>
</p>
)}
</div>
)}
<p class="small">{t.shop.subcategoryEmpty}</p>
</div>
</section>
)}
{weitere.length > 0 && (
<section class="section-tight">
<div class="container">
<div class="grid grid-3">{weitere.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</div>
</section>
)}
<section class="section-tight">
<div class="container">
<div class="btn-row">
<a class="btn btn-outline" href={`/ch/shop/${category.slug}/`}>← {category.name[lang]}</a>
<a class="btn btn-outline" href="/ch/shop/">{t.common.toShop}</a>
+48 -7
View File
@@ -26,6 +26,15 @@ const t = useTranslations(lang);
<aside class="card cart-summary">
<h3>🧾 {t.cart.subtotal}</h3>
<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="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="ship-country-row">
<label for="cart-land">📦 {t.checkout.country}</label>
<select id="cart-land">
@@ -47,17 +56,18 @@ const t = useTranslations(lang);
</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 }}>
<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 }}>
// WICHTIG: define:vars-Skripte laufen als IIFE, keine "import"-Anweisungen möglich.
window.__cartVars = { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang };
window.__cartVars = { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang, mengenrabattTemplate, couponInvalid, couponAppliedTemplate, couponRemoveLabel };
</script>
<script type="module">
import { getCart, updateQuantity, removeFromCart, cartTotal } from "../../../scripts/cart";
import { getCart, updateQuantity, removeFromCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon } from "../../../scripts/cart";
import { formatPrice } from "../../../i18n/format";
import { getProduct, products } from "../../../data/products";
import { berechneVersandkosten } from "../../../data/versand";
import { effektiverZeilenpreis, mengenrabattProzent } from "../../../data/rabatt";
const { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang } = window.__cartVars;
const { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang, mengenrabattTemplate, couponInvalid, couponAppliedTemplate, couponRemoveLabel } = window.__cartVars;
const landSelect = document.getElementById("cart-land");
function render() {
@@ -80,19 +90,23 @@ const t = useTranslations(lang);
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 zeilenpreis = produkt ? effektiverZeilenpreis(produkt, i.menge) : i.menge * i.preis;
const mengenProzent = produkt ? mengenrabattProzent(produkt, i.menge) : 0;
const mengenBadge = mengenProzent > 0 ? `<div class="small mengenrabatt-badge">${mengenrabattTemplate.replace("__P__", mengenProzent)}</div>` : "";
return `
<div class="cart-item">
${thumb}
<div class="info">
<strong>${i.name}</strong>
<div class="small">${formatPrice(i.preis, cartLang)} ${perItemLabel}</div>
<div class="small">${formatPrice(zeilenpreis / i.menge, cartLang)} ${perItemLabel}</div>
${mengenBadge}
</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(i.preis * i.menge, cartLang)}</div>
<div class="line-total">${formatPrice(zeilenpreis, cartLang)}</div>
<a href="#" class="remove" data-action="remove" data-slug="${i.slug}">✕ ${removeLabel}</a>
</div>
`;
@@ -101,12 +115,30 @@ const t = useTranslations(lang);
const subtotal = cartTotal();
const remaining = Math.max(0, freeShipFrom - subtotal);
const shipping = berechneVersandkosten(landSelect.value, cart.map((i) => ({ slug: i.slug, preis: i.preis, menge: i.menge })), products);
const coupon = appliedCoupon();
const discount = couponDiscount(subtotal);
const total = Math.max(0, subtotal - discount) + 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(subtotal + 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 : "";
}
itemsEl.querySelectorAll("button, a.remove").forEach((el) => {
el.addEventListener("click", (e) => {
e.preventDefault();
@@ -123,6 +155,15 @@ const t = useTranslations(lang);
});
}
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();
});
render();
window.addEventListener("cart:changed", render);
landSelect.addEventListener("change", render);
+64 -26
View File
@@ -18,6 +18,26 @@ const t = useTranslations(lang);
<section class="section-tight">
<div class="container checkout-layout">
<form class="frm card" id="checkout-form">
<h3>{t.checkout.step2}</h3>
<div class="payment-options">
<label class="pay-option" style="--brand-color:#009cde; --brand-bg:#003087;">
<span class="pay-icon" style="background:#003087;">P</span>
<span>PayPal</span>
<input type="radio" name="pay" value="paypal" checked />
</label>
<label class="pay-option" style="--brand-color:#ffb3c7; --brand-bg:#17120f;">
<span class="pay-icon" style="background:#17120f; color:#ffb3c7;">K</span>
<span>Klarna</span>
<input type="radio" name="pay" value="klarna" />
</label>
<label class="pay-option" style="--brand-color:#7fa8c9; --brand-bg:#3b5a76;">
<span class="pay-icon" style="background:#3b5a76;">🏦</span>
<span>{t.checkout.bankTransfer}</span>
<input type="radio" name="pay" value="ueberweisung" />
</label>
</div>
<hr class="divider" />
<h3>{t.checkout.step1}</h3>
<div><label for="email">{t.checkout.email}</label><input id="email" type="email" required /></div>
<div class="grid grid-2">
@@ -39,26 +59,6 @@ const t = useTranslations(lang);
</select>
</div>
<hr class="divider" />
<h3>{t.checkout.step2}</h3>
<div class="payment-options">
<label class="pay-option">
<span class="pay-icon" aria-hidden="true">🅿️</span>
<span>PayPal</span>
<input type="radio" name="pay" value="paypal" checked />
</label>
<label class="pay-option">
<span class="pay-icon" aria-hidden="true">💳</span>
<span>Klarna</span>
<input type="radio" name="pay" value="klarna" />
</label>
<label class="pay-option">
<span class="pay-icon" aria-hidden="true">🏦</span>
<span>{t.checkout.bankTransfer}</span>
<input type="radio" name="pay" value="ueberweisung" />
</label>
</div>
<hr class="divider" />
<h3>{t.checkout.step3}</h3>
<div id="checkout-summary" class="checkout-summary"></div>
@@ -77,6 +77,15 @@ const t = useTranslations(lang);
</form>
<aside class="card checkout-aside">
<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>
<hr class="divider" />
<h3>{t.checkout.shippingHintTitle}</h3>
<p class="small">{t.checkout.shippingHint}</p>
<a class="small" href="/versand-zahlung/">{t.checkout.shippingLink}</a>
@@ -85,20 +94,21 @@ const t = useTranslations(lang);
</section>
</Layout>
<script define:vars={{ freeLabel: t.checkout.free, totalLabel: t.checkout.total, shippingLabel: t.cart.shipping, emptyCartAlert: t.checkout.emptyCartAlert, thankYouPath: "/checkout/danke/", checkoutLang: lang }}>
<script define:vars={{ freeLabel: t.checkout.free, totalLabel: t.checkout.total, shippingLabel: t.cart.shipping, emptyCartAlert: t.checkout.emptyCartAlert, thankYouPath: "/checkout/danke/", checkoutLang: lang, discountLabel: t.cart.discount, couponInvalid: t.cart.couponInvalid, couponAppliedTemplate: t.cart.couponApplied("__C__"), couponRemoveLabel: t.cart.couponRemove }}>
// WICHTIG: define:vars-Skripte werden von Astro in ein IIFE gepackt (kein ES-Modul) — echte
// `import`-Anweisungen würden hier zur Laufzeit mit "Cannot use import statement outside a
// module" fehlschlagen. Deshalb hier nur die vom Server gerenderten Werte auf window ablegen,
// die eigentliche Logik läuft im separaten <script type="module"> darunter.
window.__checkoutVars = { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang };
window.__checkoutVars = { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang, discountLabel, couponInvalid, couponAppliedTemplate, couponRemoveLabel };
</script>
<script type="module">
import { getCart, cartTotal } from "../../scripts/cart";
import { getCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon } from "../../scripts/cart";
import { formatPrice } from "../../i18n/format";
import { products } from "../../data/products";
import { berechneVersandkosten } from "../../data/versand";
import { effektiverZeilenpreis } from "../../data/rabatt";
const { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang } = window.__checkoutVars;
const { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang, discountLabel, couponInvalid, couponAppliedTemplate, couponRemoveLabel } = window.__checkoutVars;
const summary = document.getElementById("checkout-summary");
const landSelect = document.getElementById("land");
@@ -108,17 +118,45 @@ const t = useTranslations(lang);
function renderSummary() {
const land = landSelect.value;
const shipping = cart.length === 0 ? 0 : berechneVersandkosten(land, cart.map((i) => ({ slug: i.slug, preis: i.preis, menge: i.menge })), products);
const coupon = appliedCoupon();
const discount = couponDiscount(subtotal);
const total = Math.max(0, subtotal - discount) + shipping;
summary.innerHTML = `
${cart.map((i) => `<div class="row"><span>${i.menge}× ${i.name}</span><span>${formatPrice(i.preis * i.menge, checkoutLang)}</span></div>`).join("")}
${cart.map((i) => {
const produkt = products.find((p) => p.slug === i.slug);
const zeilenpreis = produkt ? effektiverZeilenpreis(produkt, i.menge) : i.menge * i.preis;
return `<div class="row"><span>${i.menge}× ${i.name}</span><span>${formatPrice(zeilenpreis, checkoutLang)}</span></div>`;
}).join("")}
${coupon && discount > 0 ? `<div class="row"><span>${discountLabel}</span><span>-${formatPrice(discount, checkoutLang)}</span></div>` : ""}
<div class="row"><span>${shippingLabel}</span><span>${shipping === 0 ? freeLabel : formatPrice(shipping, checkoutLang)}</span></div>
<div class="row total"><span>${totalLabel}</span><span>${formatPrice(subtotal + shipping, checkoutLang)}</span></div>
<div class="row total"><span>${totalLabel}</span><span>${formatPrice(total, checkoutLang)}</span></div>
`;
const couponStatus = document.getElementById("coupon-status");
const couponInput = document.getElementById("coupon-input");
if (coupon && discount > 0) {
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(); renderSummary(); });
} else {
couponStatus.textContent = couponInput.dataset.invalid === "1" ? couponInvalid : "";
}
}
renderSummary();
landSelect.addEventListener("change", renderSummary);
document.getElementById("coupon-apply").addEventListener("click", () => {
const input = document.getElementById("coupon-input");
const code = input.value.trim();
if (!code) { clearCoupon(); renderSummary(); return; }
setCouponCode(code);
input.dataset.invalid = appliedCoupon() ? "0" : "1";
renderSummary();
});
document.getElementById("checkout-form").addEventListener("submit", (e) => {
e.preventDefault();
if (cart.length === 0) {
+63 -25
View File
@@ -18,6 +18,26 @@ const t = useTranslations(lang);
<section class="section-tight">
<div class="container checkout-layout">
<form class="frm card" id="checkout-form">
<h3>{t.checkout.step2}</h3>
<div class="payment-options">
<label class="pay-option" style="--brand-color:#009cde; --brand-bg:#003087;">
<span class="pay-icon" style="background:#003087;">P</span>
<span>PayPal</span>
<input type="radio" name="pay" value="paypal" checked />
</label>
<label class="pay-option" style="--brand-color:#ffb3c7; --brand-bg:#17120f;">
<span class="pay-icon" style="background:#17120f; color:#ffb3c7;">K</span>
<span>Klarna</span>
<input type="radio" name="pay" value="klarna" />
</label>
<label class="pay-option" style="--brand-color:#7fa8c9; --brand-bg:#3b5a76;">
<span class="pay-icon" style="background:#3b5a76;">🏦</span>
<span>{t.checkout.bankTransfer}</span>
<input type="radio" name="pay" value="ueberweisung" />
</label>
</div>
<hr class="divider" />
<h3>{t.checkout.step1}</h3>
<div><label for="email">{t.checkout.email}</label><input id="email" type="email" required /></div>
<div class="grid grid-2">
@@ -39,26 +59,6 @@ const t = useTranslations(lang);
</select>
</div>
<hr class="divider" />
<h3>{t.checkout.step2}</h3>
<div class="payment-options">
<label class="pay-option">
<span class="pay-icon" aria-hidden="true">🅿️</span>
<span>PayPal</span>
<input type="radio" name="pay" value="paypal" checked />
</label>
<label class="pay-option">
<span class="pay-icon" aria-hidden="true">💳</span>
<span>Klarna</span>
<input type="radio" name="pay" value="klarna" />
</label>
<label class="pay-option">
<span class="pay-icon" aria-hidden="true">🏦</span>
<span>{t.checkout.bankTransfer}</span>
<input type="radio" name="pay" value="ueberweisung" />
</label>
</div>
<hr class="divider" />
<h3>{t.checkout.step3}</h3>
<div id="checkout-summary" class="checkout-summary"></div>
@@ -77,6 +77,15 @@ const t = useTranslations(lang);
</form>
<aside class="card checkout-aside">
<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>
<hr class="divider" />
<h3>{t.checkout.shippingHintTitle}</h3>
<p class="small">{t.checkout.shippingHint}</p>
<a class="small" href="/en/versand-zahlung/">{t.checkout.shippingLink}</a>
@@ -87,15 +96,16 @@ const t = useTranslations(lang);
<script define:vars={{ freeLabel: t.checkout.free, totalLabel: t.checkout.total, shippingLabel: t.cart.shipping, emptyCartAlert: t.checkout.emptyCartAlert, thankYouPath: "/en/checkout/danke/", checkoutLang: lang }}>
// WICHTIG: define:vars-Skripte laufen als IIFE, keine "import"-Anweisungen möglich.
window.__checkoutVars = { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang };
window.__checkoutVars = { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang, discountLabel, couponInvalid, couponAppliedTemplate, couponRemoveLabel };
</script>
<script type="module">
import { getCart, cartTotal } from "../../../scripts/cart";
import { getCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon } from "../../../scripts/cart";
import { formatPrice } from "../../../i18n/format";
import { products } from "../../../data/products";
import { berechneVersandkosten } from "../../../data/versand";
import { effektiverZeilenpreis } from "../../../data/rabatt";
const { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang } = window.__checkoutVars;
const { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang, discountLabel, couponInvalid, couponAppliedTemplate, couponRemoveLabel } = window.__checkoutVars;
const summary = document.getElementById("checkout-summary");
const landSelect = document.getElementById("land");
@@ -105,17 +115,45 @@ const t = useTranslations(lang);
function renderSummary() {
const land = landSelect.value;
const shipping = cart.length === 0 ? 0 : berechneVersandkosten(land, cart.map((i) => ({ slug: i.slug, preis: i.preis, menge: i.menge })), products);
const coupon = appliedCoupon();
const discount = couponDiscount(subtotal);
const total = Math.max(0, subtotal - discount) + shipping;
summary.innerHTML = `
${cart.map((i) => `<div class="row"><span>${i.menge}× ${i.name}</span><span>${formatPrice(i.preis * i.menge, checkoutLang)}</span></div>`).join("")}
${cart.map((i) => {
const produkt = products.find((p) => p.slug === i.slug);
const zeilenpreis = produkt ? effektiverZeilenpreis(produkt, i.menge) : i.menge * i.preis;
return `<div class="row"><span>${i.menge}× ${i.name}</span><span>${formatPrice(zeilenpreis, checkoutLang)}</span></div>`;
}).join("")}
${coupon && discount > 0 ? `<div class="row"><span>${discountLabel}</span><span>-${formatPrice(discount, checkoutLang)}</span></div>` : ""}
<div class="row"><span>${shippingLabel}</span><span>${shipping === 0 ? freeLabel : formatPrice(shipping, checkoutLang)}</span></div>
<div class="row total"><span>${totalLabel}</span><span>${formatPrice(subtotal + shipping, checkoutLang)}</span></div>
<div class="row total"><span>${totalLabel}</span><span>${formatPrice(total, checkoutLang)}</span></div>
`;
const couponStatus = document.getElementById("coupon-status");
const couponInput = document.getElementById("coupon-input");
if (coupon && discount > 0) {
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(); renderSummary(); });
} else {
couponStatus.textContent = couponInput.dataset.invalid === "1" ? couponInvalid : "";
}
}
renderSummary();
landSelect.addEventListener("change", renderSummary);
document.getElementById("coupon-apply").addEventListener("click", () => {
const input = document.getElementById("coupon-input");
const code = input.value.trim();
if (!code) { clearCoupon(); renderSummary(); return; }
setCouponCode(code);
input.dataset.invalid = appliedCoupon() ? "0" : "1";
renderSummary();
});
document.getElementById("checkout-form").addEventListener("submit", (e) => {
e.preventDefault();
if (cart.length === 0) {
+25 -3
View File
@@ -6,6 +6,7 @@ import { getCategory } from "../../../data/categories";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
import { formatPrice } from "../../../i18n/format";
import { effektiverPreis, rabattLabel, hatAktivenRabatt } from "../../../data/rabatt";
export function getStaticPaths() {
return products.map((p) => ({ params: { slug: p.slug } }));
@@ -22,6 +23,10 @@ const name = product.name[lang];
const desc = product.beschreibung[lang];
const badgeLabel: Record<string, string> = { neu: t.badge.neu, bestseller: t.badge.bestseller, sale: t.badge.sale, handgemacht: t.badge.handgemacht };
const finalPrice = effektiverPreis(product);
const discountBadge = rabattLabel(product);
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
const mengenrabatt = product.rabatt?.mengenrabatt ?? [];
const felder: [string, string | undefined][] = [
[t.product.articleNo, product.artikelnummer],
@@ -68,21 +73,32 @@ const felder: [string, string | undefined][] = [
<h1>{name}</h1>
<div class="price-row-detail">
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
<span class="price-detail">{formatPrice(product.preis, lang)}</span>
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
<span class="price-detail">{formatPrice(finalPrice, lang)}</span>
{discountBadge && <span class="badge badge-rabatt">{discountBadge}</span>}
</div>
{mengenrabatt.length > 0 && (
<ul class="mengenrabatt-list small">
{mengenrabatt.map((stufe) => <li>{t.cart.mengenrabattTier(String(stufe.abMenge), String(stufe.prozent))}</li>)}
</ul>
)}
<p class="small">{t.common.vatNote} · {t.common.plusShipping}</p>
<p class="lead">{desc}</p>
<div class="qty-row">
<label for="qty">{t.common.quantity}</label>
<input type="number" id="qty" min="1" value="1" />
<div class="qty-stepper">
<button type="button" id="qty-dec" aria-label=""></button>
<input type="number" id="qty" min="1" value="1" inputmode="numeric" />
<button type="button" id="qty-inc" aria-label="+">+</button>
</div>
<button
class="btn btn-primary"
id="add-to-cart"
disabled={product.bestand === 0}
data-slug={product.slug}
data-name={name}
data-preis={product.preis}
data-preis={finalPrice}
>
{product.bestand === 0 ? t.common.soldOut : t.common.addToCart}
</button>
@@ -120,6 +136,12 @@ const felder: [string, string | undefined][] = [
const btn = document.getElementById("add-to-cart") as HTMLButtonElement | null;
const qtyInput = document.getElementById("qty") as HTMLInputElement;
const confirm = document.getElementById("add-confirm")!;
document.getElementById("qty-dec")?.addEventListener("click", () => {
qtyInput.value = String(Math.max(1, Number(qtyInput.value) - 1));
});
document.getElementById("qty-inc")?.addEventListener("click", () => {
qtyInput.value = String(Number(qtyInput.value) + 1);
});
btn?.addEventListener("click", () => {
const { slug, name, preis } = btn.dataset;
addToCart({ slug: slug!, name: name!, preis: Number(preis) }, Math.max(1, Number(qtyInput.value) || 1));
@@ -1,6 +1,8 @@
---
import Layout from "../../../../layouts/Layout.astro";
import ProductCard from "../../../../components/ProductCard.astro";
import { categories, getCategory, getSubcategory } from "../../../../data/categories";
import { productsByUnterkategorie } from "../../../../data/products";
import type { Locale } from "../../../../i18n/config";
import { useTranslations } from "../../../../i18n/ui";
@@ -15,6 +17,10 @@ const t = useTranslations(lang);
const { kategorie, unterkategorie } = Astro.params;
const category = getCategory(kategorie!)!;
const sub = getSubcategory(kategorie!, unterkategorie!)!;
const echtProdukte = productsByUnterkategorie(category.slug, sub.slug);
const featured = echtProdukte[0];
const weitere = echtProdukte.slice(1);
---
<Layout title={`${sub.name[lang]} ${category.name[lang]}`} description={`${sub.name[lang]} at Van's DIY & Bastelbedarf.`} lang={lang} path={`/shop/${category.slug}/${sub.slug}/`}>
<div class="container">
@@ -29,11 +35,23 @@ const sub = getSubcategory(kategorie!, unterkategorie!)!;
</section>
</div>
<section class="section-tight">
<div class="container">
{sub.vorstellung && (
<div class="character-intro">
{sub.vorstellung[lang].split("\n").map((line) => <p>{line}</p>)}
{featured ? (
<section class="section-tight">
<div class="container grid grid-2 intro">
<div class="portrait-frame portrait">
{featured.bilder && featured.bilder.length > 0 ? (
<img class="product-photo" src={featured.bilder[0]} alt={featured.name[lang]} loading="lazy" />
) : (
<div class="img-placeholder" role="img" aria-label={featured.name[lang]}></div>
)}
</div>
<div>
{sub.vorstellung && <span class="eyebrow">{sub.vorstellung[lang].split("\n")[0]}</span>}
<h2>{featured.name[lang]}</h2>
<p class="lead">{featured.beschreibung[lang]}</p>
<div class="btn-row">
<a class="btn btn-primary" href={`/en/produkt/${featured.slug}/`}>{t.common.discoverNow} →</a>
</div>
{sub.mehrLink && (
<p class="character-more">
Want to learn more about {sub.name[lang]}? Click here:{" "}
@@ -41,8 +59,37 @@ const sub = getSubcategory(kategorie!, unterkategorie!)!;
</p>
)}
</div>
)}
<p class="small">{t.shop.subcategoryEmpty}</p>
</div>
</section>
) : (
<section class="section-tight">
<div class="container">
{sub.vorstellung && (
<div class="character-intro">
{sub.vorstellung[lang].split("\n").map((line) => <p>{line}</p>)}
{sub.mehrLink && (
<p class="character-more">
Want to learn more about {sub.name[lang]}? Click here:{" "}
<a class="character-more-link" href={sub.mehrLink} target="_blank" rel="noopener">here →</a>
</p>
)}
</div>
)}
<p class="small">{t.shop.subcategoryEmpty}</p>
</div>
</section>
)}
{weitere.length > 0 && (
<section class="section-tight">
<div class="container">
<div class="grid grid-3">{weitere.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</div>
</section>
)}
<section class="section-tight">
<div class="container">
<div class="btn-row">
<a class="btn btn-outline" href={`/en/shop/${category.slug}/`}>← {category.name[lang]}</a>
<a class="btn btn-outline" href="/en/shop/">{t.common.toShop}</a>
+48 -7
View File
@@ -26,6 +26,15 @@ const t = useTranslations(lang);
<aside class="card cart-summary">
<h3>🧾 {t.cart.subtotal}</h3>
<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="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="ship-country-row">
<label for="cart-land">📦 {t.checkout.country}</label>
<select id="cart-land">
@@ -47,17 +56,18 @@ const t = useTranslations(lang);
</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 }}>
<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 }}>
// WICHTIG: define:vars-Skripte laufen als IIFE, keine "import"-Anweisungen möglich.
window.__cartVars = { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang };
window.__cartVars = { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang, mengenrabattTemplate, couponInvalid, couponAppliedTemplate, couponRemoveLabel };
</script>
<script type="module">
import { getCart, updateQuantity, removeFromCart, cartTotal } from "../../../scripts/cart";
import { getCart, updateQuantity, removeFromCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon } from "../../../scripts/cart";
import { formatPrice } from "../../../i18n/format";
import { getProduct, products } from "../../../data/products";
import { berechneVersandkosten } from "../../../data/versand";
import { effektiverZeilenpreis, mengenrabattProzent } from "../../../data/rabatt";
const { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang } = window.__cartVars;
const { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang, mengenrabattTemplate, couponInvalid, couponAppliedTemplate, couponRemoveLabel } = window.__cartVars;
const landSelect = document.getElementById("cart-land");
function render() {
@@ -80,19 +90,23 @@ const t = useTranslations(lang);
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 zeilenpreis = produkt ? effektiverZeilenpreis(produkt, i.menge) : i.menge * i.preis;
const mengenProzent = produkt ? mengenrabattProzent(produkt, i.menge) : 0;
const mengenBadge = mengenProzent > 0 ? `<div class="small mengenrabatt-badge">${mengenrabattTemplate.replace("__P__", mengenProzent)}</div>` : "";
return `
<div class="cart-item">
${thumb}
<div class="info">
<strong>${i.name}</strong>
<div class="small">${formatPrice(i.preis, cartLang)} ${perItemLabel}</div>
<div class="small">${formatPrice(zeilenpreis / i.menge, cartLang)} ${perItemLabel}</div>
${mengenBadge}
</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(i.preis * i.menge, cartLang)}</div>
<div class="line-total">${formatPrice(zeilenpreis, cartLang)}</div>
<a href="#" class="remove" data-action="remove" data-slug="${i.slug}">✕ ${removeLabel}</a>
</div>
`;
@@ -101,12 +115,30 @@ const t = useTranslations(lang);
const subtotal = cartTotal();
const remaining = Math.max(0, freeShipFrom - subtotal);
const shipping = berechneVersandkosten(landSelect.value, cart.map((i) => ({ slug: i.slug, preis: i.preis, menge: i.menge })), products);
const coupon = appliedCoupon();
const discount = couponDiscount(subtotal);
const total = Math.max(0, subtotal - discount) + 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(subtotal + 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 : "";
}
itemsEl.querySelectorAll("button, a.remove").forEach((el) => {
el.addEventListener("click", (e) => {
e.preventDefault();
@@ -123,6 +155,15 @@ const t = useTranslations(lang);
});
}
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();
});
render();
window.addEventListener("cart:changed", render);
landSelect.addEventListener("change", render);
+63 -25
View File
@@ -18,6 +18,26 @@ const t = useTranslations(lang);
<section class="section-tight">
<div class="container checkout-layout">
<form class="frm card" id="checkout-form">
<h3>{t.checkout.step2}</h3>
<div class="payment-options">
<label class="pay-option" style="--brand-color:#009cde; --brand-bg:#003087;">
<span class="pay-icon" style="background:#003087;">P</span>
<span>PayPal</span>
<input type="radio" name="pay" value="paypal" checked />
</label>
<label class="pay-option" style="--brand-color:#ffb3c7; --brand-bg:#17120f;">
<span class="pay-icon" style="background:#17120f; color:#ffb3c7;">K</span>
<span>Klarna</span>
<input type="radio" name="pay" value="klarna" />
</label>
<label class="pay-option" style="--brand-color:#7fa8c9; --brand-bg:#3b5a76;">
<span class="pay-icon" style="background:#3b5a76;">🏦</span>
<span>{t.checkout.bankTransfer}</span>
<input type="radio" name="pay" value="ueberweisung" />
</label>
</div>
<hr class="divider" />
<h3>{t.checkout.step1}</h3>
<div><label for="email">{t.checkout.email}</label><input id="email" type="email" required /></div>
<div class="grid grid-2">
@@ -39,26 +59,6 @@ const t = useTranslations(lang);
</select>
</div>
<hr class="divider" />
<h3>{t.checkout.step2}</h3>
<div class="payment-options">
<label class="pay-option">
<span class="pay-icon" aria-hidden="true">🅿️</span>
<span>PayPal</span>
<input type="radio" name="pay" value="paypal" checked />
</label>
<label class="pay-option">
<span class="pay-icon" aria-hidden="true">💳</span>
<span>Klarna</span>
<input type="radio" name="pay" value="klarna" />
</label>
<label class="pay-option">
<span class="pay-icon" aria-hidden="true">🏦</span>
<span>{t.checkout.bankTransfer}</span>
<input type="radio" name="pay" value="ueberweisung" />
</label>
</div>
<hr class="divider" />
<h3>{t.checkout.step3}</h3>
<div id="checkout-summary" class="checkout-summary"></div>
@@ -77,6 +77,15 @@ const t = useTranslations(lang);
</form>
<aside class="card checkout-aside">
<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>
<hr class="divider" />
<h3>{t.checkout.shippingHintTitle}</h3>
<p class="small">{t.checkout.shippingHint}</p>
<a class="small" href="/fr/versand-zahlung/">{t.checkout.shippingLink}</a>
@@ -87,15 +96,16 @@ const t = useTranslations(lang);
<script define:vars={{ freeLabel: t.checkout.free, totalLabel: t.checkout.total, shippingLabel: t.cart.shipping, emptyCartAlert: t.checkout.emptyCartAlert, thankYouPath: "/fr/checkout/danke/", checkoutLang: lang }}>
// WICHTIG: define:vars-Skripte laufen als IIFE, keine "import"-Anweisungen möglich.
window.__checkoutVars = { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang };
window.__checkoutVars = { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang, discountLabel, couponInvalid, couponAppliedTemplate, couponRemoveLabel };
</script>
<script type="module">
import { getCart, cartTotal } from "../../../scripts/cart";
import { getCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon } from "../../../scripts/cart";
import { formatPrice } from "../../../i18n/format";
import { products } from "../../../data/products";
import { berechneVersandkosten } from "../../../data/versand";
import { effektiverZeilenpreis } from "../../../data/rabatt";
const { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang } = window.__checkoutVars;
const { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang, discountLabel, couponInvalid, couponAppliedTemplate, couponRemoveLabel } = window.__checkoutVars;
const summary = document.getElementById("checkout-summary");
const landSelect = document.getElementById("land");
@@ -105,17 +115,45 @@ const t = useTranslations(lang);
function renderSummary() {
const land = landSelect.value;
const shipping = cart.length === 0 ? 0 : berechneVersandkosten(land, cart.map((i) => ({ slug: i.slug, preis: i.preis, menge: i.menge })), products);
const coupon = appliedCoupon();
const discount = couponDiscount(subtotal);
const total = Math.max(0, subtotal - discount) + shipping;
summary.innerHTML = `
${cart.map((i) => `<div class="row"><span>${i.menge}× ${i.name}</span><span>${formatPrice(i.preis * i.menge, checkoutLang)}</span></div>`).join("")}
${cart.map((i) => {
const produkt = products.find((p) => p.slug === i.slug);
const zeilenpreis = produkt ? effektiverZeilenpreis(produkt, i.menge) : i.menge * i.preis;
return `<div class="row"><span>${i.menge}× ${i.name}</span><span>${formatPrice(zeilenpreis, checkoutLang)}</span></div>`;
}).join("")}
${coupon && discount > 0 ? `<div class="row"><span>${discountLabel}</span><span>-${formatPrice(discount, checkoutLang)}</span></div>` : ""}
<div class="row"><span>${shippingLabel}</span><span>${shipping === 0 ? freeLabel : formatPrice(shipping, checkoutLang)}</span></div>
<div class="row total"><span>${totalLabel}</span><span>${formatPrice(subtotal + shipping, checkoutLang)}</span></div>
<div class="row total"><span>${totalLabel}</span><span>${formatPrice(total, checkoutLang)}</span></div>
`;
const couponStatus = document.getElementById("coupon-status");
const couponInput = document.getElementById("coupon-input");
if (coupon && discount > 0) {
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(); renderSummary(); });
} else {
couponStatus.textContent = couponInput.dataset.invalid === "1" ? couponInvalid : "";
}
}
renderSummary();
landSelect.addEventListener("change", renderSummary);
document.getElementById("coupon-apply").addEventListener("click", () => {
const input = document.getElementById("coupon-input");
const code = input.value.trim();
if (!code) { clearCoupon(); renderSummary(); return; }
setCouponCode(code);
input.dataset.invalid = appliedCoupon() ? "0" : "1";
renderSummary();
});
document.getElementById("checkout-form").addEventListener("submit", (e) => {
e.preventDefault();
if (cart.length === 0) {
+25 -3
View File
@@ -6,6 +6,7 @@ import { getCategory } from "../../../data/categories";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
import { formatPrice } from "../../../i18n/format";
import { effektiverPreis, rabattLabel, hatAktivenRabatt } from "../../../data/rabatt";
export function getStaticPaths() {
return products.map((p) => ({ params: { slug: p.slug } }));
@@ -22,6 +23,10 @@ const name = product.name[lang];
const desc = product.beschreibung[lang];
const badgeLabel: Record<string, string> = { neu: t.badge.neu, bestseller: t.badge.bestseller, sale: t.badge.sale, handgemacht: t.badge.handgemacht };
const finalPrice = effektiverPreis(product);
const discountBadge = rabattLabel(product);
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
const mengenrabatt = product.rabatt?.mengenrabatt ?? [];
const felder: [string, string | undefined][] = [
[t.product.articleNo, product.artikelnummer],
@@ -68,21 +73,32 @@ const felder: [string, string | undefined][] = [
<h1>{name}</h1>
<div class="price-row-detail">
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
<span class="price-detail">{formatPrice(product.preis, lang)}</span>
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
<span class="price-detail">{formatPrice(finalPrice, lang)}</span>
{discountBadge && <span class="badge badge-rabatt">{discountBadge}</span>}
</div>
{mengenrabatt.length > 0 && (
<ul class="mengenrabatt-list small">
{mengenrabatt.map((stufe) => <li>{t.cart.mengenrabattTier(String(stufe.abMenge), String(stufe.prozent))}</li>)}
</ul>
)}
<p class="small">{t.common.vatNote} · {t.common.plusShipping}</p>
<p class="lead">{desc}</p>
<div class="qty-row">
<label for="qty">{t.common.quantity}</label>
<input type="number" id="qty" min="1" value="1" />
<div class="qty-stepper">
<button type="button" id="qty-dec" aria-label=""></button>
<input type="number" id="qty" min="1" value="1" inputmode="numeric" />
<button type="button" id="qty-inc" aria-label="+">+</button>
</div>
<button
class="btn btn-primary"
id="add-to-cart"
disabled={product.bestand === 0}
data-slug={product.slug}
data-name={name}
data-preis={product.preis}
data-preis={finalPrice}
>
{product.bestand === 0 ? t.common.soldOut : t.common.addToCart}
</button>
@@ -120,6 +136,12 @@ const felder: [string, string | undefined][] = [
const btn = document.getElementById("add-to-cart") as HTMLButtonElement | null;
const qtyInput = document.getElementById("qty") as HTMLInputElement;
const confirm = document.getElementById("add-confirm")!;
document.getElementById("qty-dec")?.addEventListener("click", () => {
qtyInput.value = String(Math.max(1, Number(qtyInput.value) - 1));
});
document.getElementById("qty-inc")?.addEventListener("click", () => {
qtyInput.value = String(Number(qtyInput.value) + 1);
});
btn?.addEventListener("click", () => {
const { slug, name, preis } = btn.dataset;
addToCart({ slug: slug!, name: name!, preis: Number(preis) }, Math.max(1, Number(qtyInput.value) || 1));
@@ -1,6 +1,8 @@
---
import Layout from "../../../../layouts/Layout.astro";
import ProductCard from "../../../../components/ProductCard.astro";
import { categories, getCategory, getSubcategory } from "../../../../data/categories";
import { productsByUnterkategorie } from "../../../../data/products";
import type { Locale } from "../../../../i18n/config";
import { useTranslations } from "../../../../i18n/ui";
@@ -15,6 +17,10 @@ const t = useTranslations(lang);
const { kategorie, unterkategorie } = Astro.params;
const category = getCategory(kategorie!)!;
const sub = getSubcategory(kategorie!, unterkategorie!)!;
const echtProdukte = productsByUnterkategorie(category.slug, sub.slug);
const featured = echtProdukte[0];
const weitere = echtProdukte.slice(1);
---
<Layout title={`${sub.name[lang]} ${category.name[lang]}`} description={`${sub.name[lang]} chez Van's DIY & Bastelbedarf.`} lang={lang} path={`/shop/${category.slug}/${sub.slug}/`}>
<div class="container">
@@ -29,11 +35,23 @@ const sub = getSubcategory(kategorie!, unterkategorie!)!;
</section>
</div>
<section class="section-tight">
<div class="container">
{sub.vorstellung && (
<div class="character-intro">
{sub.vorstellung[lang].split("\n").map((line) => <p>{line}</p>)}
{featured ? (
<section class="section-tight">
<div class="container grid grid-2 intro">
<div class="portrait-frame portrait">
{featured.bilder && featured.bilder.length > 0 ? (
<img class="product-photo" src={featured.bilder[0]} alt={featured.name[lang]} loading="lazy" />
) : (
<div class="img-placeholder" role="img" aria-label={featured.name[lang]}></div>
)}
</div>
<div>
{sub.vorstellung && <span class="eyebrow">{sub.vorstellung[lang].split("\n")[0]}</span>}
<h2>{featured.name[lang]}</h2>
<p class="lead">{featured.beschreibung[lang]}</p>
<div class="btn-row">
<a class="btn btn-primary" href={`/fr/produkt/${featured.slug}/`}>{t.common.discoverNow} →</a>
</div>
{sub.mehrLink && (
<p class="character-more">
Tu veux en savoir plus sur {sub.name[lang]} ? Clique ici :{" "}
@@ -41,8 +59,37 @@ const sub = getSubcategory(kategorie!, unterkategorie!)!;
</p>
)}
</div>
)}
<p class="small">{t.shop.subcategoryEmpty}</p>
</div>
</section>
) : (
<section class="section-tight">
<div class="container">
{sub.vorstellung && (
<div class="character-intro">
{sub.vorstellung[lang].split("\n").map((line) => <p>{line}</p>)}
{sub.mehrLink && (
<p class="character-more">
Tu veux en savoir plus sur {sub.name[lang]} ? Clique ici :{" "}
<a class="character-more-link" href={sub.mehrLink} target="_blank" rel="noopener">ici →</a>
</p>
)}
</div>
)}
<p class="small">{t.shop.subcategoryEmpty}</p>
</div>
</section>
)}
{weitere.length > 0 && (
<section class="section-tight">
<div class="container">
<div class="grid grid-3">{weitere.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</div>
</section>
)}
<section class="section-tight">
<div class="container">
<div class="btn-row">
<a class="btn btn-outline" href={`/fr/shop/${category.slug}/`}>← {category.name[lang]}</a>
<a class="btn btn-outline" href="/fr/shop/">{t.common.toShop}</a>
+48 -7
View File
@@ -26,6 +26,15 @@ const t = useTranslations(lang);
<aside class="card cart-summary">
<h3>🧾 {t.cart.subtotal}</h3>
<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="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="ship-country-row">
<label for="cart-land">📦 {t.checkout.country}</label>
<select id="cart-land">
@@ -47,17 +56,18 @@ const t = useTranslations(lang);
</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 }}>
<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 }}>
// WICHTIG: define:vars-Skripte laufen als IIFE, keine "import"-Anweisungen möglich.
window.__cartVars = { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang };
window.__cartVars = { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang, mengenrabattTemplate, couponInvalid, couponAppliedTemplate, couponRemoveLabel };
</script>
<script type="module">
import { getCart, updateQuantity, removeFromCart, cartTotal } from "../../../scripts/cart";
import { getCart, updateQuantity, removeFromCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon } from "../../../scripts/cart";
import { formatPrice } from "../../../i18n/format";
import { getProduct, products } from "../../../data/products";
import { berechneVersandkosten } from "../../../data/versand";
import { effektiverZeilenpreis, mengenrabattProzent } from "../../../data/rabatt";
const { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang } = window.__cartVars;
const { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang, mengenrabattTemplate, couponInvalid, couponAppliedTemplate, couponRemoveLabel } = window.__cartVars;
const landSelect = document.getElementById("cart-land");
function render() {
@@ -80,19 +90,23 @@ const t = useTranslations(lang);
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 zeilenpreis = produkt ? effektiverZeilenpreis(produkt, i.menge) : i.menge * i.preis;
const mengenProzent = produkt ? mengenrabattProzent(produkt, i.menge) : 0;
const mengenBadge = mengenProzent > 0 ? `<div class="small mengenrabatt-badge">${mengenrabattTemplate.replace("__P__", mengenProzent)}</div>` : "";
return `
<div class="cart-item">
${thumb}
<div class="info">
<strong>${i.name}</strong>
<div class="small">${formatPrice(i.preis, cartLang)} ${perItemLabel}</div>
<div class="small">${formatPrice(zeilenpreis / i.menge, cartLang)} ${perItemLabel}</div>
${mengenBadge}
</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(i.preis * i.menge, cartLang)}</div>
<div class="line-total">${formatPrice(zeilenpreis, cartLang)}</div>
<a href="#" class="remove" data-action="remove" data-slug="${i.slug}">✕ ${removeLabel}</a>
</div>
`;
@@ -101,12 +115,30 @@ const t = useTranslations(lang);
const subtotal = cartTotal();
const remaining = Math.max(0, freeShipFrom - subtotal);
const shipping = berechneVersandkosten(landSelect.value, cart.map((i) => ({ slug: i.slug, preis: i.preis, menge: i.menge })), products);
const coupon = appliedCoupon();
const discount = couponDiscount(subtotal);
const total = Math.max(0, subtotal - discount) + 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(subtotal + 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 : "";
}
itemsEl.querySelectorAll("button, a.remove").forEach((el) => {
el.addEventListener("click", (e) => {
e.preventDefault();
@@ -123,6 +155,15 @@ const t = useTranslations(lang);
});
}
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();
});
render();
window.addEventListener("cart:changed", render);
landSelect.addEventListener("change", render);
+25 -3
View File
@@ -6,6 +6,7 @@ import { getCategory } from "../../data/categories";
import type { Locale } from "../../i18n/config";
import { useTranslations } from "../../i18n/ui";
import { formatPrice } from "../../i18n/format";
import { effektiverPreis, rabattLabel, hatAktivenRabatt } from "../../data/rabatt";
export function getStaticPaths() {
return products.map((p) => ({ params: { slug: p.slug } }));
@@ -22,6 +23,10 @@ const name = product.name[lang];
const desc = product.beschreibung[lang];
const badgeLabel: Record<string, string> = { neu: t.badge.neu, bestseller: t.badge.bestseller, sale: t.badge.sale, handgemacht: t.badge.handgemacht };
const finalPrice = effektiverPreis(product);
const discountBadge = rabattLabel(product);
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
const mengenrabatt = product.rabatt?.mengenrabatt ?? [];
const felder: [string, string | undefined][] = [
[t.product.articleNo, product.artikelnummer],
@@ -68,21 +73,32 @@ const felder: [string, string | undefined][] = [
<h1>{name}</h1>
<div class="price-row-detail">
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
<span class="price-detail">{formatPrice(product.preis, lang)}</span>
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
<span class="price-detail">{formatPrice(finalPrice, lang)}</span>
{discountBadge && <span class="badge badge-rabatt">{discountBadge}</span>}
</div>
{mengenrabatt.length > 0 && (
<ul class="mengenrabatt-list small">
{mengenrabatt.map((stufe) => <li>{t.cart.mengenrabattTier(String(stufe.abMenge), String(stufe.prozent))}</li>)}
</ul>
)}
<p class="small">{t.common.vatNote} · {t.common.plusShipping}</p>
<p class="lead">{desc}</p>
<div class="qty-row">
<label for="qty">{t.common.quantity}</label>
<input type="number" id="qty" min="1" value="1" />
<div class="qty-stepper">
<button type="button" id="qty-dec" aria-label=""></button>
<input type="number" id="qty" min="1" value="1" inputmode="numeric" />
<button type="button" id="qty-inc" aria-label="+">+</button>
</div>
<button
class="btn btn-primary"
id="add-to-cart"
disabled={product.bestand === 0}
data-slug={product.slug}
data-name={name}
data-preis={product.preis}
data-preis={finalPrice}
>
{product.bestand === 0 ? t.common.soldOut : t.common.addToCart}
</button>
@@ -120,6 +136,12 @@ const felder: [string, string | undefined][] = [
const btn = document.getElementById("add-to-cart") as HTMLButtonElement | null;
const qtyInput = document.getElementById("qty") as HTMLInputElement;
const confirm = document.getElementById("add-confirm")!;
document.getElementById("qty-dec")?.addEventListener("click", () => {
qtyInput.value = String(Math.max(1, Number(qtyInput.value) - 1));
});
document.getElementById("qty-inc")?.addEventListener("click", () => {
qtyInput.value = String(Number(qtyInput.value) + 1);
});
btn?.addEventListener("click", () => {
const { slug, name, preis } = btn.dataset;
addToCart({ slug: slug!, name: name!, preis: Number(preis) }, Math.max(1, Number(qtyInput.value) || 1));
@@ -1,6 +1,8 @@
---
import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.astro";
import { categories, getCategory, getSubcategory } from "../../../data/categories";
import { productsByUnterkategorie } from "../../../data/products";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
@@ -15,6 +17,13 @@ const t = useTranslations(lang);
const { kategorie, unterkategorie } = Astro.params;
const category = getCategory(kategorie!)!;
const sub = getSubcategory(kategorie!, unterkategorie!)!;
// Sobald VanVan im Adminbereich ein echtes Produkt dieser Unterkategorie zuordnet, wird es
// hier automatisch featured angezeigt (echtes Foto + ihr echter Text) statt des generischen
// Platzhaltertexts.
const echtProdukte = productsByUnterkategorie(category.slug, sub.slug);
const featured = echtProdukte[0];
const weitere = echtProdukte.slice(1);
---
<Layout title={`${sub.name[lang]} ${category.name[lang]}`} description={`${sub.name[lang]} bei Van's DIY & Bastelbedarf.`} lang={lang} path={`/shop/${category.slug}/${sub.slug}/`}>
<div class="container">
@@ -29,11 +38,23 @@ const sub = getSubcategory(kategorie!, unterkategorie!)!;
</section>
</div>
<section class="section-tight">
<div class="container">
{sub.vorstellung && (
<div class="character-intro">
{sub.vorstellung[lang].split("\n").map((line) => <p>{line}</p>)}
{featured ? (
<section class="section-tight">
<div class="container grid grid-2 intro">
<div class="portrait-frame portrait">
{featured.bilder && featured.bilder.length > 0 ? (
<img class="product-photo" src={featured.bilder[0]} alt={featured.name[lang]} loading="lazy" />
) : (
<div class="img-placeholder" role="img" aria-label={featured.name[lang]}></div>
)}
</div>
<div>
{sub.vorstellung && <span class="eyebrow">{sub.vorstellung[lang].split("\n")[0]}</span>}
<h2>{featured.name[lang]}</h2>
<p class="lead">{featured.beschreibung[lang]}</p>
<div class="btn-row">
<a class="btn btn-primary" href={`/produkt/${featured.slug}/`}>{t.common.discoverNow} →</a>
</div>
{sub.mehrLink && (
<p class="character-more">
Du willst mehr über {sub.name[lang]} erfahren, dann klicke hier:{" "}
@@ -41,8 +62,37 @@ const sub = getSubcategory(kategorie!, unterkategorie!)!;
</p>
)}
</div>
)}
<p class="small">{t.shop.subcategoryEmpty}</p>
</div>
</section>
) : (
<section class="section-tight">
<div class="container">
{sub.vorstellung && (
<div class="character-intro">
{sub.vorstellung[lang].split("\n").map((line) => <p>{line}</p>)}
{sub.mehrLink && (
<p class="character-more">
Du willst mehr über {sub.name[lang]} erfahren, dann klicke hier:{" "}
<a class="character-more-link" href={sub.mehrLink} target="_blank" rel="noopener">hier →</a>
</p>
)}
</div>
)}
<p class="small">{t.shop.subcategoryEmpty}</p>
</div>
</section>
)}
{weitere.length > 0 && (
<section class="section-tight">
<div class="container">
<div class="grid grid-3">{weitere.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</div>
</section>
)}
<section class="section-tight">
<div class="container">
<div class="btn-row">
<a class="btn btn-outline" href={`/shop/${category.slug}/`}>← {category.name[lang]}</a>
<a class="btn btn-outline" href="/shop/">{t.common.toShop}</a>
+48 -7
View File
@@ -26,6 +26,15 @@ const t = useTranslations(lang);
<aside class="card cart-summary">
<h3>🧾 {t.cart.subtotal}</h3>
<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="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="ship-country-row">
<label for="cart-land">📦 {t.checkout.country}</label>
<select id="cart-land">
@@ -47,17 +56,18 @@ const t = useTranslations(lang);
</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 }}>
<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 }}>
// WICHTIG: define:vars-Skripte laufen als IIFE, keine "import"-Anweisungen möglich.
window.__cartVars = { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang };
window.__cartVars = { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang, mengenrabattTemplate, couponInvalid, couponAppliedTemplate, couponRemoveLabel };
</script>
<script type="module">
import { getCart, updateQuantity, removeFromCart, cartTotal } from "../../scripts/cart";
import { getCart, updateQuantity, removeFromCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon } from "../../scripts/cart";
import { formatPrice } from "../../i18n/format";
import { getProduct, products } from "../../data/products";
import { berechneVersandkosten } from "../../data/versand";
import { effektiverZeilenpreis, mengenrabattProzent } from "../../data/rabatt";
const { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang } = window.__cartVars;
const { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, freeLabel, cartLang, mengenrabattTemplate, couponInvalid, couponAppliedTemplate, couponRemoveLabel } = window.__cartVars;
const landSelect = document.getElementById("cart-land");
function render() {
@@ -80,19 +90,23 @@ const t = useTranslations(lang);
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 zeilenpreis = produkt ? effektiverZeilenpreis(produkt, i.menge) : i.menge * i.preis;
const mengenProzent = produkt ? mengenrabattProzent(produkt, i.menge) : 0;
const mengenBadge = mengenProzent > 0 ? `<div class="small mengenrabatt-badge">${mengenrabattTemplate.replace("__P__", mengenProzent)}</div>` : "";
return `
<div class="cart-item">
${thumb}
<div class="info">
<strong>${i.name}</strong>
<div class="small">${formatPrice(i.preis, cartLang)} ${perItemLabel}</div>
<div class="small">${formatPrice(zeilenpreis / i.menge, cartLang)} ${perItemLabel}</div>
${mengenBadge}
</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(i.preis * i.menge, cartLang)}</div>
<div class="line-total">${formatPrice(zeilenpreis, cartLang)}</div>
<a href="#" class="remove" data-action="remove" data-slug="${i.slug}">✕ ${removeLabel}</a>
</div>
`;
@@ -101,12 +115,30 @@ const t = useTranslations(lang);
const subtotal = cartTotal();
const remaining = Math.max(0, freeShipFrom - subtotal);
const shipping = berechneVersandkosten(landSelect.value, cart.map((i) => ({ slug: i.slug, preis: i.preis, menge: i.menge })), products);
const coupon = appliedCoupon();
const discount = couponDiscount(subtotal);
const total = Math.max(0, subtotal - discount) + 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(subtotal + 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 : "";
}
itemsEl.querySelectorAll("button, a.remove").forEach((el) => {
el.addEventListener("click", (e) => {
e.preventDefault();
@@ -123,6 +155,15 @@ const t = useTranslations(lang);
});
}
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();
});
render();
window.addEventListener("cart:changed", render);
landSelect.addEventListener("change", render);