Versandkosten-System (Pflichtenheft Punkt 9) + kritischen Warenkorb/Checkout-Bug behoben

Neu:
- Produkte können eigene Versandkosten je Land (DE/AT/CH/LU) bekommen, gelten nur bei
  Einzelkauf (genau 1 Stück von 1 Produkt)
- Versandkosten-Staffel nach Warenwert je Land, von VanVan im Adminbereich bearbeitbar
  (neuer Bereich "Versandkosten-Staffel"), greift bei mehreren Produkten/Stück
- Versandkostenfrei ab 75 € gilt immer, unabhängig von Einzelkauf/Staffel
- Checkout berechnet und zeigt jetzt die echten Versandkosten live beim Länderwechsel an

Kritischer Bugfix (unabhängig vom neuen Feature entdeckt):
- Warenkorb- und Checkout-Skripte haben "import" innerhalb von define:vars-Skripten benutzt,
  was Astro als IIFE rendert (kein ES-Modul) — das hat in der echten Live-Version die ganze
  Zeit zu "Cannot use import statement outside a module" geführt, wodurch Warenkorb und
  Checkout praktisch nie funktioniert haben. Behoben durch Trennung: define:vars-Skript setzt
  nur Werte auf window, separates <script type="module"> mit den echten Imports liest sie aus.
  Mit echtem End-to-End-Test verifiziert (Artikel in Warenkorb legen, Land wechseln, Summe
  prüfen) statt nur Screenshot.
This commit is contained in:
qcigano
2026-08-02 17:56:21 +02:00
parent 49cf12fd1c
commit 094edaaf39
12 changed files with 282 additions and 40 deletions
+51
View File
@@ -92,6 +92,16 @@ collections:
- { label: Bestseller, value: bestseller }
- { label: "Im Sale", value: sale }
- { label: Handgemacht, value: handgemacht }
- label: "🚚 Eigene Versandkosten (nur für Einzelkauf dieses Produkts)"
name: versand
widget: object
required: false
hint: "Gilt NUR, wenn jemand genau 1 Stück von genau diesem Produkt kauft. Bei mehreren Produkten/Stück im Warenkorb gilt automatisch die allgemeine Versandkosten-Staffel (siehe eigener Bereich 'Versandkosten-Staffel' im Menü links). Leer lassen = Staffel gilt auch beim Einzelkauf."
fields:
- { label: "Deutschland (€)", name: de, widget: number, value_type: float, step: 0.01, required: false }
- { label: "Österreich (€)", name: at, widget: number, value_type: float, step: 0.01, required: false }
- { label: "Schweiz (€)", name: ch, widget: number, value_type: float, step: 0.01, required: false }
- { label: "Luxemburg (€)", name: lu, widget: number, value_type: float, step: 0.01, required: false }
- name: categories
label: Kategorien
@@ -147,3 +157,44 @@ collections:
- { label: Englisch, name: en, widget: string }
- { label: "Schweizerdeutsch", name: ch, widget: string }
- { label: Französisch, name: fr, widget: string }
- name: versandkosten
label: "🚚 Versandkosten-Staffel"
files:
- name: staffel
label: "Versandkosten-Staffel nach Warenwert"
file: src/content/versandstaffel.json
description: >
Gilt automatisch im Warenkorb/an der Kasse, sobald jemand mehrere Produkte oder
mehrere Stück kauft (bei genau 1 Stück von 1 Produkt gilt stattdessen der eigene
Versandpreis am Produkt, falls dort einer eingetragen ist). Ab 75 € Warenwert ist der
Versand immer kostenlos, unabhängig von dieser Tabelle.
fields:
- label: Deutschland
name: de
widget: list
summary: "ab {{fields.abWarenwert}} € → {{fields.kosten}} €"
fields:
- { label: "Ab Warenwert (€)", name: abWarenwert, widget: number, value_type: float, step: 0.01 }
- { label: "Versandkosten (€)", name: kosten, widget: number, value_type: float, step: 0.01 }
- label: Österreich
name: at
widget: list
summary: "ab {{fields.abWarenwert}} € → {{fields.kosten}} €"
fields:
- { label: "Ab Warenwert (€)", name: abWarenwert, widget: number, value_type: float, step: 0.01 }
- { label: "Versandkosten (€)", name: kosten, widget: number, value_type: float, step: 0.01 }
- label: Schweiz
name: ch
widget: list
summary: "ab {{fields.abWarenwert}} € → {{fields.kosten}} €"
fields:
- { label: "Ab Warenwert (€)", name: abWarenwert, widget: number, value_type: float, step: 0.01 }
- { label: "Versandkosten (€)", name: kosten, widget: number, value_type: float, step: 0.01 }
- label: Luxemburg
name: lu
widget: list
summary: "ab {{fields.abWarenwert}} € → {{fields.kosten}} €"
fields:
- { label: "Ab Warenwert (€)", name: abWarenwert, widget: number, value_type: float, step: 0.01 }
- { label: "Versandkosten (€)", name: kosten, widget: number, value_type: float, step: 0.01 }
+22
View File
@@ -0,0 +1,22 @@
{
"de": [
{ "abWarenwert": 0, "kosten": 4.95 },
{ "abWarenwert": 25, "kosten": 3.95 },
{ "abWarenwert": 50, "kosten": 2.95 }
],
"at": [
{ "abWarenwert": 0, "kosten": 6.95 },
{ "abWarenwert": 25, "kosten": 5.95 },
{ "abWarenwert": 50, "kosten": 4.95 }
],
"ch": [
{ "abWarenwert": 0, "kosten": 9.95 },
{ "abWarenwert": 25, "kosten": 8.95 },
{ "abWarenwert": 50, "kosten": 7.95 }
],
"lu": [
{ "abWarenwert": 0, "kosten": 6.95 },
{ "abWarenwert": 25, "kosten": 5.95 },
{ "abWarenwert": 50, "kosten": 4.95 }
]
}
+5
View File
@@ -29,6 +29,11 @@ export interface Product {
lieferzeit?: string;
badges: Badge[];
bilder?: string[]; // Pfade zu Produktfotos, von VanVan im Admin-Bereich hochgeladen
// Eigene Versandkosten je Land — gelten NUR, wenn genau 1 Stück dieses einen Produkts
// gekauft wird (Einzelkauf). Bei mehreren Produkten/Stückzahlen greift stattdessen die
// Versandkosten-Staffel (siehe src/data/versand.ts). Optional, damit Bestandsprodukte ohne
// diese Angabe nicht kaputtgehen — dann greift automatisch die Staffel als Rückfalllösung.
versand?: { de?: number; at?: number; ch?: number; lu?: number };
}
const modules = import.meta.glob("/src/content/products/*.json", { eager: true }) as Record<
+73
View File
@@ -0,0 +1,73 @@
// Versandkosten-Logik. Regeln (mit dem Nutzer am 02.08.2026 abgestimmt):
// 1. Warenkorb-Gesamtwert >= 75 € → Versand immer kostenlos, egal wie viele Produkte/Stück.
// 2. Genau 1 Stück von genau 1 Produkt im Warenkorb → der am Produkt selbst hinterlegte
// Versandpreis für das Zielland gilt (falls hinterlegt, sonst Fallback auf die Staffel).
// 3. Alles andere (mehrere Produkte und/oder mehrere Stück) → Versandkosten-Staffel nach
// Gesamt-Warenwert für das Zielland (src/content/versandstaffel.json), von VanVan im
// Adminbereich bearbeitbar.
import type { Product } from "./products";
export type CountryCode = "de" | "at" | "ch" | "lu";
export interface VersandStufe {
abWarenwert: number;
kosten: number;
}
export interface Versandstaffel {
de: VersandStufe[];
at: VersandStufe[];
ch: VersandStufe[];
lu: VersandStufe[];
}
// @ts-ignore -- JSON-Import, zur Build-Zeit von Astro/Vite aufgelöst
import versandstaffelRaw from "../content/versandstaffel.json";
export const versandstaffel = versandstaffelRaw as Versandstaffel;
export const FREE_SHIPPING_FROM = 75;
function tierLookup(land: CountryCode, warenwert: number): number {
const stufen = versandstaffel[land] ?? [];
const sortiert = [...stufen].sort((a, b) => a.abWarenwert - b.abWarenwert);
let kosten = sortiert[0]?.kosten ?? 0;
for (const s of sortiert) {
if (warenwert >= s.abWarenwert) kosten = s.kosten;
}
return kosten;
}
export interface MinimalCartItem {
slug: string;
preis: number;
menge: number;
}
/**
* Berechnet die Versandkosten für einen Warenkorb in ein bestimmtes Land.
* `alleProdukte` wird gebraucht, um bei Einzelkäufen den produktspezifischen Versandpreis
* nachzuschlagen (die Cart-Items selbst kennen nur slug/preis/menge, keine Versanddaten).
*/
export function berechneVersandkosten(
land: CountryCode,
items: MinimalCartItem[],
alleProdukte: Product[]
): number {
if (items.length === 0) return 0;
const warenwert = items.reduce((sum, i) => sum + i.preis * i.menge, 0);
if (warenwert >= FREE_SHIPPING_FROM) return 0;
const gesamtmenge = items.reduce((sum, i) => sum + i.menge, 0);
const istEinzelkauf = items.length === 1 && gesamtmenge === 1;
if (istEinzelkauf) {
const produkt = alleProdukte.find((p) => p.slug === items[0].slug);
const eigenerPreis = produkt?.versand?.[land];
if (typeof eigenerPreis === "number") return eigenerPreis;
// Kein eigener Versandpreis hinterlegt -> Staffel als Rückfalllösung
}
return tierLookup(land, warenwert);
}
+21 -5
View File
@@ -32,10 +32,10 @@ const t = useTranslations(lang);
<div>
<label for="land">{t.checkout.country}</label>
<select id="land">
<option selected>Schwiz</option>
<option>Dütschland</option>
<option>Öschterrych</option>
<option>Luxemburg</option>
<option value="ch" selected>Schwiz</option>
<option value="de">Dütschland</option>
<option value="at">Öschterrych</option>
<option value="lu">Luxemburg</option>
</select>
</div>
@@ -74,19 +74,35 @@ const t = useTranslations(lang);
</Layout>
<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 };
</script>
<script type="module">
import { getCart, cartTotal } from "../../../scripts/cart";
import { formatPrice } from "../../../i18n/format";
import { products } from "../../../data/products";
import { berechneVersandkosten } from "../../../data/versand";
const { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang } = window.__checkoutVars;
const summary = document.getElementById("checkout-summary");
const landSelect = document.getElementById("land");
const cart = getCart();
const subtotal = cartTotal();
const shipping = cart.length === 0 ? 0 : subtotal >= 75 ? 0 : 4.95;
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);
summary.innerHTML = `
${cart.map((i) => `<div class="row"><span>${i.menge}× ${i.name}</span><span>${formatPrice(i.preis * i.menge, checkoutLang)}</span></div>`).join("")}
<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>
`;
}
renderSummary();
landSelect.addEventListener("change", renderSummary);
document.getElementById("checkout-form").addEventListener("submit", (e) => {
e.preventDefault();
+6
View File
@@ -39,9 +39,15 @@ const t = useTranslations(lang);
</Layout>
<script define:vars={{ freeShipFrom: 75, removeLabel: t.cart.remove, perItemLabel: t.cart.perItem, remainingTemplate: t.cart.remaining("__V__"), freeShippingText: t.cart.freeShipping, cartLang: lang }}>
// WICHTIG: define:vars-Skripte laufen als IIFE, keine "import"-Anweisungen möglich.
window.__cartVars = { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, cartLang };
</script>
<script type="module">
import { getCart, updateQuantity, removeFromCart, cartTotal } from "../../../scripts/cart";
import { formatPrice } from "../../../i18n/format";
const { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, cartLang } = window.__cartVars;
function render() {
const cart = getCart();
const empty = document.getElementById("cart-empty");
+24 -5
View File
@@ -32,10 +32,10 @@ const t = useTranslations(lang);
<div>
<label for="land">{t.checkout.country}</label>
<select id="land">
<option>Deutschland</option>
<option>Österreich</option>
<option>Schweiz</option>
<option>Luxemburg</option>
<option value="de">Deutschland</option>
<option value="at">Österreich</option>
<option value="ch">Schweiz</option>
<option value="lu">Luxemburg</option>
</select>
</div>
@@ -74,19 +74,38 @@ const t = useTranslations(lang);
</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 }}>
// 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 };
</script>
<script type="module">
import { getCart, cartTotal } from "../../scripts/cart";
import { formatPrice } from "../../i18n/format";
import { products } from "../../data/products";
import { berechneVersandkosten } from "../../data/versand";
const { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang } = window.__checkoutVars;
const summary = document.getElementById("checkout-summary");
const landSelect = document.getElementById("land");
const cart = getCart();
const subtotal = cartTotal();
const shipping = cart.length === 0 ? 0 : subtotal >= 75 ? 0 : 4.95;
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);
summary.innerHTML = `
${cart.map((i) => `<div class="row"><span>${i.menge}× ${i.name}</span><span>${formatPrice(i.preis * i.menge, checkoutLang)}</span></div>`).join("")}
<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>
`;
}
renderSummary();
landSelect.addEventListener("change", renderSummary);
document.getElementById("checkout-form").addEventListener("submit", (e) => {
e.preventDefault();
+21 -5
View File
@@ -32,10 +32,10 @@ const t = useTranslations(lang);
<div>
<label for="land">{t.checkout.country}</label>
<select id="land">
<option>Germany</option>
<option>Austria</option>
<option>Switzerland</option>
<option>Luxembourg</option>
<option value="de">Germany</option>
<option value="at">Austria</option>
<option value="ch">Switzerland</option>
<option value="lu">Luxembourg</option>
</select>
</div>
@@ -74,19 +74,35 @@ const t = useTranslations(lang);
</Layout>
<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 };
</script>
<script type="module">
import { getCart, cartTotal } from "../../../scripts/cart";
import { formatPrice } from "../../../i18n/format";
import { products } from "../../../data/products";
import { berechneVersandkosten } from "../../../data/versand";
const { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang } = window.__checkoutVars;
const summary = document.getElementById("checkout-summary");
const landSelect = document.getElementById("land");
const cart = getCart();
const subtotal = cartTotal();
const shipping = cart.length === 0 ? 0 : subtotal >= 75 ? 0 : 4.95;
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);
summary.innerHTML = `
${cart.map((i) => `<div class="row"><span>${i.menge}× ${i.name}</span><span>${formatPrice(i.preis * i.menge, checkoutLang)}</span></div>`).join("")}
<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>
`;
}
renderSummary();
landSelect.addEventListener("change", renderSummary);
document.getElementById("checkout-form").addEventListener("submit", (e) => {
e.preventDefault();
+6
View File
@@ -39,9 +39,15 @@ const t = useTranslations(lang);
</Layout>
<script define:vars={{ freeShipFrom: 75, removeLabel: t.cart.remove, perItemLabel: t.cart.perItem, remainingTemplate: t.cart.remaining("__V__"), freeShippingText: t.cart.freeShipping, cartLang: lang }}>
// WICHTIG: define:vars-Skripte laufen als IIFE, keine "import"-Anweisungen möglich.
window.__cartVars = { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, cartLang };
</script>
<script type="module">
import { getCart, updateQuantity, removeFromCart, cartTotal } from "../../../scripts/cart";
import { formatPrice } from "../../../i18n/format";
const { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, cartLang } = window.__cartVars;
function render() {
const cart = getCart();
const empty = document.getElementById("cart-empty");
+21 -5
View File
@@ -32,10 +32,10 @@ const t = useTranslations(lang);
<div>
<label for="land">{t.checkout.country}</label>
<select id="land">
<option>Allemagne</option>
<option>Autriche</option>
<option>Suisse</option>
<option>Luxembourg</option>
<option value="de">Allemagne</option>
<option value="at">Autriche</option>
<option value="ch">Suisse</option>
<option value="lu">Luxembourg</option>
</select>
</div>
@@ -74,19 +74,35 @@ const t = useTranslations(lang);
</Layout>
<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 };
</script>
<script type="module">
import { getCart, cartTotal } from "../../../scripts/cart";
import { formatPrice } from "../../../i18n/format";
import { products } from "../../../data/products";
import { berechneVersandkosten } from "../../../data/versand";
const { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang } = window.__checkoutVars;
const summary = document.getElementById("checkout-summary");
const landSelect = document.getElementById("land");
const cart = getCart();
const subtotal = cartTotal();
const shipping = cart.length === 0 ? 0 : subtotal >= 75 ? 0 : 4.95;
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);
summary.innerHTML = `
${cart.map((i) => `<div class="row"><span>${i.menge}× ${i.name}</span><span>${formatPrice(i.preis * i.menge, checkoutLang)}</span></div>`).join("")}
<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>
`;
}
renderSummary();
landSelect.addEventListener("change", renderSummary);
document.getElementById("checkout-form").addEventListener("submit", (e) => {
e.preventDefault();
+6
View File
@@ -39,9 +39,15 @@ const t = useTranslations(lang);
</Layout>
<script define:vars={{ freeShipFrom: 75, removeLabel: t.cart.remove, perItemLabel: t.cart.perItem, remainingTemplate: t.cart.remaining("__V__"), freeShippingText: t.cart.freeShipping, cartLang: lang }}>
// WICHTIG: define:vars-Skripte laufen als IIFE, keine "import"-Anweisungen möglich.
window.__cartVars = { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, cartLang };
</script>
<script type="module">
import { getCart, updateQuantity, removeFromCart, cartTotal } from "../../../scripts/cart";
import { formatPrice } from "../../../i18n/format";
const { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, cartLang } = window.__cartVars;
function render() {
const cart = getCart();
const empty = document.getElementById("cart-empty");
+6
View File
@@ -39,9 +39,15 @@ const t = useTranslations(lang);
</Layout>
<script define:vars={{ freeShipFrom: 75, removeLabel: t.cart.remove, perItemLabel: t.cart.perItem, remainingTemplate: t.cart.remaining("__V__"), freeShippingText: t.cart.freeShipping, cartLang: lang }}>
// WICHTIG: define:vars-Skripte laufen als IIFE, keine "import"-Anweisungen möglich.
window.__cartVars = { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, cartLang };
</script>
<script type="module">
import { getCart, updateQuantity, removeFromCart, cartTotal } from "../../scripts/cart";
import { formatPrice } from "../../i18n/format";
const { freeShipFrom, removeLabel, perItemLabel, remainingTemplate, freeShippingText, cartLang } = window.__cartVars;
function render() {
const cart = getCart();
const empty = document.getElementById("cart-empty");