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:
@@ -63,6 +63,24 @@ collections:
|
|||||||
step: 0.01
|
step: 0.01
|
||||||
required: false
|
required: false
|
||||||
hint: "Hier den ALTEN, höheren Preis eintragen — er wird dann durchgestrichen neben dem neuen Preis angezeigt. Leer lassen, wenn kein Sale."
|
hint: "Hier den ALTEN, höheren Preis eintragen — er wird dann durchgestrichen neben dem neuen Preis angezeigt. Leer lassen, wenn kein Sale."
|
||||||
|
- label: "🏷️ Rabatte (optional — alles leer lassen, wenn kein automatischer Rabatt gelten soll)"
|
||||||
|
name: rabatt
|
||||||
|
widget: object
|
||||||
|
required: false
|
||||||
|
hint: "Zusätzlich zum 'Reduzierten Preis' oben kannst du hier einen automatischen Rabatt einrichten, der von selbst wieder abläuft, sowie einen Mengenrabatt (z.B. ab 3 Stück günstiger)."
|
||||||
|
fields:
|
||||||
|
- { label: "Rabatt in Prozent (z.B. 20 für 20%)", name: prozent, widget: number, value_type: int, required: false, hint: "Nur ausfüllen ODER den Betrag unten — nicht beides." }
|
||||||
|
- { label: "ODER: Rabatt als fester Betrag (€)", name: betrag, widget: number, value_type: float, step: 0.01, required: false }
|
||||||
|
- { label: "Rabatt gültig bis (leer = läuft nicht automatisch ab)", name: bis, widget: date, format: "YYYY-MM-DD", required: false, hint: "Nach diesem Datum verschwindet der Rabatt automatisch — ohne dass du etwas tun musst." }
|
||||||
|
- label: "Mengenrabatt-Staffel (z.B. ab 3 Stück 10% Rabatt)"
|
||||||
|
name: mengenrabatt
|
||||||
|
widget: list
|
||||||
|
required: false
|
||||||
|
summary: "ab {{fields.abMenge}} Stück → -{{fields.prozent}}%"
|
||||||
|
hint: "Gilt automatisch im Warenkorb, wenn jemand mehrere Stück dieses Produkts kauft."
|
||||||
|
fields:
|
||||||
|
- { label: "Ab wie vielen Stück", name: abMenge, widget: number, value_type: int }
|
||||||
|
- { label: "Rabatt in Prozent", name: prozent, widget: number, value_type: int }
|
||||||
- label: "Auf Lager"
|
- label: "Auf Lager"
|
||||||
name: bestand
|
name: bestand
|
||||||
widget: number
|
widget: number
|
||||||
@@ -214,3 +232,24 @@ collections:
|
|||||||
fields:
|
fields:
|
||||||
- { label: "Ab Warenwert (€)", name: abWarenwert, widget: number, value_type: float, step: 0.01 }
|
- { 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: "Versandkosten (€)", name: kosten, widget: number, value_type: float, step: 0.01 }
|
||||||
|
|
||||||
|
- name: gutscheine
|
||||||
|
label: "🎟️ Gutscheincodes"
|
||||||
|
icon: local_offer
|
||||||
|
files:
|
||||||
|
- name: codes
|
||||||
|
label: "Gutscheincodes"
|
||||||
|
file: src/content/gutscheine.json
|
||||||
|
description: >
|
||||||
|
Codes, die Kund:innen im Warenkorb/an der Kasse eingeben können, um Rabatt auf den
|
||||||
|
gesamten Warenwert zu bekommen (z.B. für Aktionen, Newsletter, Stammkund:innen).
|
||||||
|
fields:
|
||||||
|
- label: Gutscheincodes
|
||||||
|
name: codes
|
||||||
|
widget: list
|
||||||
|
summary: "{{fields.code}}"
|
||||||
|
fields:
|
||||||
|
- { label: "Code (z.B. WILLKOMMEN10)", name: code, widget: string, hint: "Groß-/Kleinschreibung ist egal — wird automatisch angepasst." }
|
||||||
|
- { label: "Rabatt in Prozent (z.B. 10 für 10%)", name: prozent, widget: number, value_type: int, required: false }
|
||||||
|
- { label: "ODER: Rabatt als fester Betrag (€)", name: betrag, widget: number, value_type: float, step: 0.01, required: false }
|
||||||
|
- { label: "Gültig bis (leer = unbegrenzt)", name: bis, widget: date, format: "YYYY-MM-DD", required: false }
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import type { Locale } from "../i18n/config";
|
|||||||
import { localePrefix } from "../i18n/config";
|
import { localePrefix } from "../i18n/config";
|
||||||
import { useTranslations } from "../i18n/ui";
|
import { useTranslations } from "../i18n/ui";
|
||||||
import { formatPrice } from "../i18n/format";
|
import { formatPrice } from "../i18n/format";
|
||||||
|
import { effektiverPreis, rabattLabel, hatAktivenRabatt } from "../data/rabatt";
|
||||||
|
|
||||||
interface Props { product: Product; lang: Locale }
|
interface Props { product: Product; lang: Locale }
|
||||||
const { product, lang } = Astro.props;
|
const { product, lang } = Astro.props;
|
||||||
const t = useTranslations(lang);
|
const t = useTranslations(lang);
|
||||||
|
const finalPrice = effektiverPreis(product);
|
||||||
|
const discountBadge = rabattLabel(product);
|
||||||
|
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
|
||||||
|
|
||||||
const badgeLabel: Record<string, string> = {
|
const badgeLabel: Record<string, string> = {
|
||||||
neu: t.badge.neu,
|
neu: t.badge.neu,
|
||||||
@@ -32,13 +36,15 @@ const desc = product.beschreibung[lang];
|
|||||||
<span class={`badge ${b === "sale" ? "badge-sale" : ""}`}>{badgeLabel[b]}</span>
|
<span class={`badge ${b === "sale" ? "badge-sale" : ""}`}>{badgeLabel[b]}</span>
|
||||||
))}
|
))}
|
||||||
{product.bestand === 0 && <span class="badge badge-sold-out">{t.badge.ausverkauft}</span>}
|
{product.bestand === 0 && <span class="badge badge-sold-out">{t.badge.ausverkauft}</span>}
|
||||||
|
{discountBadge && <span class="badge badge-rabatt">{discountBadge}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3>{name}</h3>
|
<h3>{name}</h3>
|
||||||
<p class="small">{desc.slice(0, 78)}{desc.length > 78 ? "…" : ""}</p>
|
<p class="small">{desc.slice(0, 78)}{desc.length > 78 ? "…" : ""}</p>
|
||||||
<div class="price-row">
|
<div class="price-row">
|
||||||
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
|
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
|
||||||
<span class="price">{formatPrice(product.preis, lang)}</span>
|
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
|
||||||
|
<span class="price">{formatPrice(finalPrice, lang)}</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -53,4 +59,5 @@ const desc = product.beschreibung[lang];
|
|||||||
.price-row { margin-top: auto; display: flex; align-items: baseline; gap: 0.6rem; }
|
.price-row { margin-top: auto; display: flex; align-items: baseline; gap: 0.6rem; }
|
||||||
.price { color: var(--c-accent); font-weight: 700; font-size: 1.15rem; }
|
.price { color: var(--c-accent); font-weight: 700; font-size: 1.15rem; }
|
||||||
.price-old { color: var(--c-text-muted); text-decoration: line-through; font-size: 0.9rem; }
|
.price-old { color: var(--c-text-muted); text-decoration: line-through; font-size: 0.9rem; }
|
||||||
|
.badge-rabatt { background: #ff5f7e; color: #fff; border-color: #ff5f7e; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"codes": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Gutscheincodes — site-weit gültig, von VanVan im Adminbereich unter "🎟️ Gutscheincodes"
|
||||||
|
// verwaltet. Werden im Warenkorb/Checkout eingelöst und auf den Gesamt-Warenwert angewendet.
|
||||||
|
|
||||||
|
// @ts-ignore -- JSON-Import, zur Build-Zeit von Astro/Vite aufgelöst
|
||||||
|
import gutscheineRaw from "../content/gutscheine.json";
|
||||||
|
|
||||||
|
export interface Gutschein {
|
||||||
|
code: string;
|
||||||
|
prozent?: number;
|
||||||
|
betrag?: number;
|
||||||
|
bis?: string; // ISO-Datum, optional
|
||||||
|
}
|
||||||
|
|
||||||
|
const gutscheine = (gutscheineRaw as { codes: Gutschein[] }).codes ?? [];
|
||||||
|
|
||||||
|
function istGueltig(bis?: string): boolean {
|
||||||
|
if (!bis) return true;
|
||||||
|
return new Date() <= new Date(`${bis}T23:59:59`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sucht einen Gutschein anhand des eingegebenen Codes (Groß-/Kleinschreibung egal). */
|
||||||
|
export function findeGutschein(code: string): Gutschein | undefined {
|
||||||
|
const normalisiert = code.trim().toUpperCase();
|
||||||
|
return gutscheine.find((g) => g.code.toUpperCase() === normalisiert && istGueltig(g.bis));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Berechnet den Rabatt-Betrag in Euro für einen gegebenen Warenwert. */
|
||||||
|
export function gutscheinRabatt(gutschein: Gutschein, warenwert: number): number {
|
||||||
|
if (typeof gutschein.prozent === "number" && gutschein.prozent > 0) {
|
||||||
|
return Math.min(warenwert, warenwert * (gutschein.prozent / 100));
|
||||||
|
}
|
||||||
|
if (typeof gutschein.betrag === "number" && gutschein.betrag > 0) {
|
||||||
|
return Math.min(warenwert, gutschein.betrag);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -34,6 +34,15 @@ export interface Product {
|
|||||||
// Versandkosten-Staffel (siehe src/data/versand.ts). Optional, damit Bestandsprodukte ohne
|
// Versandkosten-Staffel (siehe src/data/versand.ts). Optional, damit Bestandsprodukte ohne
|
||||||
// diese Angabe nicht kaputtgehen — dann greift automatisch die Staffel als Rückfalllösung.
|
// diese Angabe nicht kaputtgehen — dann greift automatisch die Staffel als Rückfalllösung.
|
||||||
versand?: { de?: number; at?: number; ch?: number; lu?: number };
|
versand?: { de?: number; at?: number; ch?: number; lu?: number };
|
||||||
|
// Rabatte — mehrere Arten gleichzeitig möglich (siehe src/data/rabatt.ts für die Logik):
|
||||||
|
// 1. Prozent/Betrag-Rabatt, optional zeitlich befristet (läuft automatisch ab).
|
||||||
|
// 2. Mengenrabatt-Staffel (ab X Stück Y% Rabatt), wird im Warenkorb automatisch angewendet.
|
||||||
|
rabatt?: {
|
||||||
|
prozent?: number; // z.B. 20 = 20% Rabatt auf den Preis
|
||||||
|
betrag?: number; // Alternative zu prozent: fester Euro-Betrag Rabatt
|
||||||
|
bis?: string; // ISO-Datum, z.B. "2026-12-31" — Rabatt gilt nur bis einschließlich diesem Tag
|
||||||
|
mengenrabatt?: { abMenge: number; prozent: number }[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const modules = import.meta.glob("/src/content/products/*.json", { eager: true }) as Record<
|
const modules = import.meta.glob("/src/content/products/*.json", { eager: true }) as Record<
|
||||||
@@ -53,6 +62,10 @@ export function productsByCategory(kategorie: string): Product[] {
|
|||||||
return products.filter((p) => p.kategorie === kategorie);
|
return products.filter((p) => p.kategorie === kategorie);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function productsByUnterkategorie(kategorie: string, unterkategorie: string): Product[] {
|
||||||
|
return products.filter((p) => p.kategorie === kategorie && p.unterkategorie === unterkategorie);
|
||||||
|
}
|
||||||
|
|
||||||
export function saleProducts(): Product[] {
|
export function saleProducts(): Product[] {
|
||||||
return products.filter((p) => p.badges.includes("sale") || p.preisAlt);
|
return products.filter((p) => p.badges.includes("sale") || p.preisAlt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Rabatt-Logik. Unterstützt "alle Art von Rabatten" (Wunsch vom 02.08.2026):
|
||||||
|
// 1. Klassischer Sale-Preis (bereits vorhanden: `preis`/`preisAlt`, manuell gesetzt).
|
||||||
|
// 2. Prozentualer oder fester Euro-Rabatt am Produkt, automatisch berechnet, optional
|
||||||
|
// zeitlich befristet (läuft am Stichtag von selbst wieder ab, ohne dass VanVan etwas
|
||||||
|
// manuell zurückstellen muss).
|
||||||
|
// 3. Mengenrabatt-Staffel (z.B. "ab 3 Stück 10% Rabatt") — wird im Warenkorb automatisch
|
||||||
|
// auf die Zeile angewendet.
|
||||||
|
// 4. Gutscheincodes (site-weit, siehe src/data/gutscheine.ts) — im Warenkorb/Checkout einlösbar.
|
||||||
|
|
||||||
|
import type { Product } from "./products";
|
||||||
|
|
||||||
|
/** Prüft, ob ein befristeter Rabatt heute noch gültig ist (Vergleich läuft im Browser zur
|
||||||
|
* echten aktuellen Uhrzeit, nicht nur zur Build-Zeit — der Rabatt läuft also wirklich
|
||||||
|
* automatisch ab, auch ohne neuen Deploy). */
|
||||||
|
export function istRabattAktiv(bis?: string): boolean {
|
||||||
|
if (!bis) return true;
|
||||||
|
const heute = new Date();
|
||||||
|
const ende = new Date(`${bis}T23:59:59`);
|
||||||
|
return heute <= ende;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Berechnet den effektiven Verkaufspreis eines Produkts unter Berücksichtigung von
|
||||||
|
* Prozent-/Betrag-Rabatt (falls aktiv). Reiner Sale-Preis über `preisAlt` bleibt unabhängig
|
||||||
|
* davon bestehen und wird hier NICHT verändert — beide Mechanismen sind unabhängig
|
||||||
|
* kombinierbar (z.B. Sale-Preis als Basis, zusätzlich zeitlich befristete Rabattaktion oben drauf). */
|
||||||
|
export function effektiverPreis(product: Product): number {
|
||||||
|
const basis = product.preis;
|
||||||
|
const r = product.rabatt;
|
||||||
|
if (!r || !istRabattAktiv(r.bis)) return basis;
|
||||||
|
if (typeof r.prozent === "number" && r.prozent > 0) {
|
||||||
|
return Math.max(0, basis * (1 - r.prozent / 100));
|
||||||
|
}
|
||||||
|
if (typeof r.betrag === "number" && r.betrag > 0) {
|
||||||
|
return Math.max(0, basis - r.betrag);
|
||||||
|
}
|
||||||
|
return basis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gibt an, ob gerade ein aktiver Rabatt greift (für Badge-Anzeige "-20%" o.ä.). */
|
||||||
|
export function hatAktivenRabatt(product: Product): boolean {
|
||||||
|
return effektiverPreis(product) < product.preis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Für die Badge-Anzeige: "-20%" oder "-5 €", je nachdem was hinterlegt ist. */
|
||||||
|
export function rabattLabel(product: Product): string | null {
|
||||||
|
const r = product.rabatt;
|
||||||
|
if (!r || !istRabattAktiv(r.bis)) return null;
|
||||||
|
if (typeof r.prozent === "number" && r.prozent > 0) return `-${r.prozent}%`;
|
||||||
|
if (typeof r.betrag === "number" && r.betrag > 0) return `-${r.betrag.toFixed(2).replace(".", ",")} €`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mengenrabatt: findet den besten (höchsten) Prozentsatz, der bei der gegebenen Stückzahl
|
||||||
|
* greift. Gibt 0 zurück, wenn kein Mengenrabatt hinterlegt ist oder die Menge zu klein ist. */
|
||||||
|
export function mengenrabattProzent(product: Product, menge: number): number {
|
||||||
|
const stufen = product.rabatt?.mengenrabatt ?? [];
|
||||||
|
let bester = 0;
|
||||||
|
for (const stufe of stufen) {
|
||||||
|
if (menge >= stufe.abMenge && stufe.prozent > bester) bester = stufe.prozent;
|
||||||
|
}
|
||||||
|
return bester;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effektiver Zeilenpreis (Einzelpreis inkl. Rabatt × Menge, zusätzlich mit Mengenrabatt
|
||||||
|
* verrechnet) — das ist die Zahl, die im Warenkorb pro Position angezeigt/summiert wird. */
|
||||||
|
export function effektiverZeilenpreis(product: Product, menge: number): number {
|
||||||
|
const einzelpreis = effektiverPreis(product);
|
||||||
|
const mengenProzent = mengenrabattProzent(product, menge);
|
||||||
|
const zeile = einzelpreis * menge;
|
||||||
|
return mengenProzent > 0 ? zeile * (1 - mengenProzent / 100) : zeile;
|
||||||
|
}
|
||||||
+28
-12
@@ -21,7 +21,7 @@ export const ui = {
|
|||||||
common: {
|
common: {
|
||||||
addToCart: "In den Warenkorb", soldOut: "Ausverkauft", addedToCart: "Zum Warenkorb hinzugefügt ✓",
|
addToCart: "In den Warenkorb", soldOut: "Ausverkauft", addedToCart: "Zum Warenkorb hinzugefügt ✓",
|
||||||
browseShop: "Jetzt stöbern", viewAll: "Alle ansehen →", viewAllSale: "Alle Angebote →",
|
browseShop: "Jetzt stöbern", viewAll: "Alle ansehen →", viewAllSale: "Alle Angebote →",
|
||||||
toShop: "Zum Shop →", moreAboutMe: "Mehr über mich →",
|
toShop: "Zum Shop →", moreAboutMe: "Mehr über mich →", discoverNow: "Jetzt entdecken",
|
||||||
vatNote: "Gemäß §19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.",
|
vatNote: "Gemäß §19 UStG wird keine Umsatzsteuer berechnet und ausgewiesen.",
|
||||||
allPricesPlusShipping: "Alle Preise zzgl. Versand.", plusShipping: "zzgl. Versand",
|
allPricesPlusShipping: "Alle Preise zzgl. Versand.", plusShipping: "zzgl. Versand",
|
||||||
quantity: "Anzahl", photoSoon: "Produktfoto folgt", portraitSoon: "Porträtfoto von VanVan folgt",
|
quantity: "Anzahl", photoSoon: "Produktfoto folgt", portraitSoon: "Porträtfoto von VanVan folgt",
|
||||||
@@ -149,13 +149,17 @@ export const ui = {
|
|||||||
subtotal: "Zwischensumme", shipping: "Versand", shippingCalculated: "wird im Checkout berechnet",
|
subtotal: "Zwischensumme", shipping: "Versand", shippingCalculated: "wird im Checkout berechnet",
|
||||||
total: "Gesamt", remaining: (v: string) => `Noch ${v} bis zum kostenlosen Versand.`, freeShipping: "🩵 Du bekommst kostenlosen Versand!",
|
total: "Gesamt", remaining: (v: string) => `Noch ${v} bis zum kostenlosen Versand.`, freeShipping: "🩵 Du bekommst kostenlosen Versand!",
|
||||||
toCheckout: "Weiter zum Checkout", remove: "Entfernen", perItem: "/ Stück",
|
toCheckout: "Weiter zum Checkout", remove: "Entfernen", perItem: "/ Stück",
|
||||||
|
discount: "Rabatt", mengenrabatt: (p: string) => `Mengenrabatt -${p}%`,
|
||||||
|
mengenrabattTier: (ab: string, p: string) => `ab ${ab} Stück -${p}%`,
|
||||||
|
couponLabel: "🎟️ Gutscheincode", couponPlaceholder: "Code eingeben", couponApply: "Einlösen", couponRemove: "Entfernen",
|
||||||
|
couponInvalid: "Code ungültig oder abgelaufen.", couponApplied: (code: string) => `Gutschein „${code}" eingelöst`,
|
||||||
},
|
},
|
||||||
checkout: {
|
checkout: {
|
||||||
eyebrow: "Checkout", title: "Bestellung abschließen",
|
eyebrow: "Checkout", title: "Bestellung abschließen",
|
||||||
todoNote: "Phase 1 – Demo-Checkout. Es findet noch keine echte Zahlung statt. Die vollständige Anbindung an PayPal, Klarna und Banküberweisung sowie die Bestellverwaltung folgen in Phase 2 (Cloudflare Worker + D1, siehe Tech-Stack-Entscheidung im Projekt-Vault).",
|
todoNote: "Phase 1 – Demo-Checkout. Es findet noch keine echte Zahlung statt. Die vollständige Anbindung an PayPal, Klarna und Banküberweisung sowie die Bestellverwaltung folgen in Phase 2 (Cloudflare Worker + D1, siehe Tech-Stack-Entscheidung im Projekt-Vault).",
|
||||||
step1: "1. Kontakt & Lieferadresse", email: "E-Mail", firstName: "Vorname", lastName: "Nachname",
|
step1: "2. Kontakt & Lieferadresse", email: "E-Mail", firstName: "Vorname", lastName: "Nachname",
|
||||||
street: "Straße & Hausnummer", zip: "PLZ", city: "Ort", country: "Land",
|
street: "Straße & Hausnummer", zip: "PLZ", city: "Ort", country: "Land",
|
||||||
step2: "2. Zahlungsart",
|
step2: "1. Zahlungsart",
|
||||||
step3: "3. Bestellübersicht", agbPrefix: "Ich habe die", agbLink: "AGB", agbSuffix: "gelesen und akzeptiere sie.",
|
step3: "3. Bestellübersicht", agbPrefix: "Ich habe die", agbLink: "AGB", agbSuffix: "gelesen und akzeptiere sie.",
|
||||||
revocationPrefix: "Ich habe die", revocationLink: "Widerrufsbelehrung", revocationAnd: "und",
|
revocationPrefix: "Ich habe die", revocationLink: "Widerrufsbelehrung", revocationAnd: "und",
|
||||||
privacyLink: "Datenschutzerklärung", revocationSuffix: "zur Kenntnis genommen.",
|
privacyLink: "Datenschutzerklärung", revocationSuffix: "zur Kenntnis genommen.",
|
||||||
@@ -190,7 +194,7 @@ export const ui = {
|
|||||||
common: {
|
common: {
|
||||||
addToCart: "Add to cart", soldOut: "Sold out", addedToCart: "Added to cart ✓",
|
addToCart: "Add to cart", soldOut: "Sold out", addedToCart: "Added to cart ✓",
|
||||||
browseShop: "Browse now", viewAll: "View all →", viewAllSale: "View all offers →",
|
browseShop: "Browse now", viewAll: "View all →", viewAllSale: "View all offers →",
|
||||||
toShop: "To the shop →", moreAboutMe: "More about me →",
|
toShop: "To the shop →", moreAboutMe: "More about me →", discoverNow: "Discover now",
|
||||||
vatNote: "Under §19 UStG (German small-business rule) no VAT is charged or shown.",
|
vatNote: "Under §19 UStG (German small-business rule) no VAT is charged or shown.",
|
||||||
allPricesPlusShipping: "All prices plus shipping.", plusShipping: "plus shipping",
|
allPricesPlusShipping: "All prices plus shipping.", plusShipping: "plus shipping",
|
||||||
quantity: "Quantity", photoSoon: "Product photo coming soon", portraitSoon: "VanVan's portrait photo coming soon",
|
quantity: "Quantity", photoSoon: "Product photo coming soon", portraitSoon: "VanVan's portrait photo coming soon",
|
||||||
@@ -318,13 +322,17 @@ export const ui = {
|
|||||||
subtotal: "Subtotal", shipping: "Shipping", shippingCalculated: "calculated at checkout",
|
subtotal: "Subtotal", shipping: "Shipping", shippingCalculated: "calculated at checkout",
|
||||||
total: "Total", remaining: (v: string) => `${v} more for free shipping.`, freeShipping: "🩵 You qualify for free shipping!",
|
total: "Total", remaining: (v: string) => `${v} more for free shipping.`, freeShipping: "🩵 You qualify for free shipping!",
|
||||||
toCheckout: "Continue to checkout", remove: "Remove", perItem: "/ item",
|
toCheckout: "Continue to checkout", remove: "Remove", perItem: "/ item",
|
||||||
|
discount: "Discount", mengenrabatt: (p: string) => `Bulk discount -${p}%`,
|
||||||
|
mengenrabattTier: (ab: string, p: string) => `from ${ab} pcs -${p}%`,
|
||||||
|
couponLabel: "🎟️ Coupon code", couponPlaceholder: "Enter code", couponApply: "Apply", couponRemove: "Remove",
|
||||||
|
couponInvalid: "Code invalid or expired.", couponApplied: (code: string) => `Coupon "${code}" applied`,
|
||||||
},
|
},
|
||||||
checkout: {
|
checkout: {
|
||||||
eyebrow: "Checkout", title: "Complete your order",
|
eyebrow: "Checkout", title: "Complete your order",
|
||||||
todoNote: "Phase 1 – demo checkout. No real payment is processed yet. Full integration with PayPal, Klarna and bank transfer, plus order management, follow in Phase 2 (Cloudflare Worker + D1, see the tech-stack decision in the project vault).",
|
todoNote: "Phase 1 – demo checkout. No real payment is processed yet. Full integration with PayPal, Klarna and bank transfer, plus order management, follow in Phase 2 (Cloudflare Worker + D1, see the tech-stack decision in the project vault).",
|
||||||
step1: "1. Contact & shipping address", email: "Email", firstName: "First name", lastName: "Last name",
|
step1: "2. Contact & shipping address", email: "Email", firstName: "First name", lastName: "Last name",
|
||||||
street: "Street & house number", zip: "ZIP code", city: "City", country: "Country",
|
street: "Street & house number", zip: "ZIP code", city: "City", country: "Country",
|
||||||
step2: "2. Payment method",
|
step2: "1. Payment method",
|
||||||
step3: "3. Order summary", agbPrefix: "I have read the", agbLink: "terms & conditions", agbSuffix: "and accept them.",
|
step3: "3. Order summary", agbPrefix: "I have read the", agbLink: "terms & conditions", agbSuffix: "and accept them.",
|
||||||
revocationPrefix: "I have taken note of the", revocationLink: "withdrawal notice", revocationAnd: "and",
|
revocationPrefix: "I have taken note of the", revocationLink: "withdrawal notice", revocationAnd: "and",
|
||||||
privacyLink: "privacy policy", revocationSuffix: ".",
|
privacyLink: "privacy policy", revocationSuffix: ".",
|
||||||
@@ -359,7 +367,7 @@ export const ui = {
|
|||||||
common: {
|
common: {
|
||||||
addToCart: "In Warenchorb", soldOut: "Usverkauft", addedToCart: "Zum Warenchorb dezuegfüegt ✓",
|
addToCart: "In Warenchorb", soldOut: "Usverkauft", addedToCart: "Zum Warenchorb dezuegfüegt ✓",
|
||||||
browseShop: "Jetzt use luege", viewAll: "Alles aluege →", viewAllSale: "Alli Aktione →",
|
browseShop: "Jetzt use luege", viewAll: "Alles aluege →", viewAllSale: "Alli Aktione →",
|
||||||
toShop: "Zum Shop →", moreAboutMe: "Meh über mich →",
|
toShop: "Zum Shop →", moreAboutMe: "Meh über mich →", discoverNow: "Jetzt aluege",
|
||||||
vatNote: "Gemäss §19 UStG (dütsches Rächt) wird kei Umsatzsteuer berechnet und usgwiese.",
|
vatNote: "Gemäss §19 UStG (dütsches Rächt) wird kei Umsatzsteuer berechnet und usgwiese.",
|
||||||
allPricesPlusShipping: "Alli Pryse zzgl. Versand.", plusShipping: "zzgl. Versand",
|
allPricesPlusShipping: "Alli Pryse zzgl. Versand.", plusShipping: "zzgl. Versand",
|
||||||
quantity: "Aazahl", photoSoon: "Produktfoto chunnt bald", portraitSoon: "Porträtfoto vo VanVan chunnt bald",
|
quantity: "Aazahl", photoSoon: "Produktfoto chunnt bald", portraitSoon: "Porträtfoto vo VanVan chunnt bald",
|
||||||
@@ -487,13 +495,17 @@ export const ui = {
|
|||||||
subtotal: "Zwüschesumme", shipping: "Versand", shippingCalculated: "wird im Checkout berechnet",
|
subtotal: "Zwüschesumme", shipping: "Versand", shippingCalculated: "wird im Checkout berechnet",
|
||||||
total: "Total", remaining: (v: string) => `No ${v} bis zum Gratisversand.`, freeShipping: "🩵 Du übercho't Gratisversand!",
|
total: "Total", remaining: (v: string) => `No ${v} bis zum Gratisversand.`, freeShipping: "🩵 Du übercho't Gratisversand!",
|
||||||
toCheckout: "Wyter zum Checkout", remove: "Ewägmache", perItem: "/ Stuck",
|
toCheckout: "Wyter zum Checkout", remove: "Ewägmache", perItem: "/ Stuck",
|
||||||
|
discount: "Rabatt", mengenrabatt: (p: string) => `Mengerabatt -${p}%`,
|
||||||
|
mengenrabattTier: (ab: string, p: string) => `ab ${ab} Stuck -${p}%`,
|
||||||
|
couponLabel: "🎟️ Gutschy-Code", couponPlaceholder: "Code igäh", couponApply: "Ilöse", couponRemove: "Ewägmache",
|
||||||
|
couponInvalid: "Code ungültig oder abgloffe.", couponApplied: (code: string) => `Gutschy „${code}" iglöst`,
|
||||||
},
|
},
|
||||||
checkout: {
|
checkout: {
|
||||||
eyebrow: "Checkout", title: "Bschtellig abschliesse",
|
eyebrow: "Checkout", title: "Bschtellig abschliesse",
|
||||||
todoNote: "Phase 1 – Demo-Checkout. Es git no kei echti Zahlig. D'vollständigi Aabindig a PayPal, Klarna und Banküberwysig sowie d'Bstellverwaltig chöme i Phase 2.",
|
todoNote: "Phase 1 – Demo-Checkout. Es git no kei echti Zahlig. D'vollständigi Aabindig a PayPal, Klarna und Banküberwysig sowie d'Bstellverwaltig chöme i Phase 2.",
|
||||||
step1: "1. Kontakt & Lieferadrässe", email: "E-Mail", firstName: "Vorname", lastName: "Nachname",
|
step1: "2. Kontakt & Lieferadrässe", email: "E-Mail", firstName: "Vorname", lastName: "Nachname",
|
||||||
street: "Strasse & Huusnummere", zip: "PLZ", city: "Ort", country: "Land",
|
street: "Strasse & Huusnummere", zip: "PLZ", city: "Ort", country: "Land",
|
||||||
step2: "2. Zahligsart",
|
step2: "1. Zahligsart",
|
||||||
step3: "3. Bschtellübersicht", agbPrefix: "Ich ha d'", agbLink: "AGB", agbSuffix: "gläse und akzeptiere si.",
|
step3: "3. Bschtellübersicht", agbPrefix: "Ich ha d'", agbLink: "AGB", agbSuffix: "gläse und akzeptiere si.",
|
||||||
revocationPrefix: "Ich ha d'", revocationLink: "Widerrufsbelehrig", revocationAnd: "und d'",
|
revocationPrefix: "Ich ha d'", revocationLink: "Widerrufsbelehrig", revocationAnd: "und d'",
|
||||||
privacyLink: "Datenschutzerklärig", revocationSuffix: "zur Kenntnis gnoh.",
|
privacyLink: "Datenschutzerklärig", revocationSuffix: "zur Kenntnis gnoh.",
|
||||||
@@ -528,7 +540,7 @@ export const ui = {
|
|||||||
common: {
|
common: {
|
||||||
addToCart: "Ajouter au panier", soldOut: "Épuisé", addedToCart: "Ajouté au panier ✓",
|
addToCart: "Ajouter au panier", soldOut: "Épuisé", addedToCart: "Ajouté au panier ✓",
|
||||||
browseShop: "Découvrir la boutique", viewAll: "Tout voir →", viewAllSale: "Toutes les offres →",
|
browseShop: "Découvrir la boutique", viewAll: "Tout voir →", viewAllSale: "Toutes les offres →",
|
||||||
toShop: "Vers la boutique →", moreAboutMe: "En savoir plus sur moi →",
|
toShop: "Vers la boutique →", moreAboutMe: "En savoir plus sur moi →", discoverNow: "Découvrir maintenant",
|
||||||
vatNote: "Conformément à l'§19 UStG (droit allemand), aucune TVA n'est calculée ni indiquée.",
|
vatNote: "Conformément à l'§19 UStG (droit allemand), aucune TVA n'est calculée ni indiquée.",
|
||||||
allPricesPlusShipping: "Tous les prix hors frais de port.", plusShipping: "hors frais de port",
|
allPricesPlusShipping: "Tous les prix hors frais de port.", plusShipping: "hors frais de port",
|
||||||
quantity: "Quantité", photoSoon: "Photo du produit à venir", portraitSoon: "Photo de VanVan à venir",
|
quantity: "Quantité", photoSoon: "Photo du produit à venir", portraitSoon: "Photo de VanVan à venir",
|
||||||
@@ -656,13 +668,17 @@ export const ui = {
|
|||||||
subtotal: "Sous-total", shipping: "Livraison", shippingCalculated: "calculée au moment du paiement",
|
subtotal: "Sous-total", shipping: "Livraison", shippingCalculated: "calculée au moment du paiement",
|
||||||
total: "Total", remaining: (v: string) => `Encore ${v} avant la livraison gratuite.`, freeShipping: "🩵 Vous bénéficiez de la livraison gratuite !",
|
total: "Total", remaining: (v: string) => `Encore ${v} avant la livraison gratuite.`, freeShipping: "🩵 Vous bénéficiez de la livraison gratuite !",
|
||||||
toCheckout: "Passer au paiement", remove: "Retirer", perItem: "/ pièce",
|
toCheckout: "Passer au paiement", remove: "Retirer", perItem: "/ pièce",
|
||||||
|
discount: "Remise", mengenrabatt: (p: string) => `Remise de quantité -${p}%`,
|
||||||
|
mengenrabattTier: (ab: string, p: string) => `dès ${ab} pièces -${p}%`,
|
||||||
|
couponLabel: "🎟️ Code promo", couponPlaceholder: "Entrer le code", couponApply: "Valider", couponRemove: "Retirer",
|
||||||
|
couponInvalid: "Code invalide ou expiré.", couponApplied: (code: string) => `Code promo « ${code} » appliqué`,
|
||||||
},
|
},
|
||||||
checkout: {
|
checkout: {
|
||||||
eyebrow: "Paiement", title: "Finaliser la commande",
|
eyebrow: "Paiement", title: "Finaliser la commande",
|
||||||
todoNote: "Phase 1 – paiement de démonstration. Aucun paiement réel n'est encore traité. L'intégration complète avec PayPal, Klarna et le virement bancaire, ainsi que la gestion des commandes, suivront en phase 2.",
|
todoNote: "Phase 1 – paiement de démonstration. Aucun paiement réel n'est encore traité. L'intégration complète avec PayPal, Klarna et le virement bancaire, ainsi que la gestion des commandes, suivront en phase 2.",
|
||||||
step1: "1. Contact & adresse de livraison", email: "E-mail", firstName: "Prénom", lastName: "Nom",
|
step1: "2. Contact & adresse de livraison", email: "E-mail", firstName: "Prénom", lastName: "Nom",
|
||||||
street: "Rue & numéro", zip: "Code postal", city: "Ville", country: "Pays",
|
street: "Rue & numéro", zip: "Code postal", city: "Ville", country: "Pays",
|
||||||
step2: "2. Moyen de paiement",
|
step2: "1. Moyen de paiement",
|
||||||
step3: "3. Récapitulatif de commande", agbPrefix: "J'ai lu les", agbLink: "CGV", agbSuffix: "et je les accepte.",
|
step3: "3. Récapitulatif de commande", agbPrefix: "J'ai lu les", agbLink: "CGV", agbSuffix: "et je les accepte.",
|
||||||
revocationPrefix: "J'ai pris connaissance de la", revocationLink: "notice de rétractation", revocationAnd: "et de la",
|
revocationPrefix: "J'ai pris connaissance de la", revocationLink: "notice de rétractation", revocationAnd: "et de la",
|
||||||
privacyLink: "politique de confidentialité", revocationSuffix: ".",
|
privacyLink: "politique de confidentialité", revocationSuffix: ".",
|
||||||
|
|||||||
@@ -18,6 +18,26 @@ const t = useTranslations(lang);
|
|||||||
<section class="section-tight">
|
<section class="section-tight">
|
||||||
<div class="container checkout-layout">
|
<div class="container checkout-layout">
|
||||||
<form class="frm card" id="checkout-form">
|
<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>
|
<h3>{t.checkout.step1}</h3>
|
||||||
<div><label for="email">{t.checkout.email}</label><input id="email" type="email" required /></div>
|
<div><label for="email">{t.checkout.email}</label><input id="email" type="email" required /></div>
|
||||||
<div class="grid grid-2">
|
<div class="grid grid-2">
|
||||||
@@ -39,26 +59,6 @@ const t = useTranslations(lang);
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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" />
|
<hr class="divider" />
|
||||||
<h3>{t.checkout.step3}</h3>
|
<h3>{t.checkout.step3}</h3>
|
||||||
<div id="checkout-summary" class="checkout-summary"></div>
|
<div id="checkout-summary" class="checkout-summary"></div>
|
||||||
@@ -77,6 +77,15 @@ const t = useTranslations(lang);
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<aside class="card checkout-aside">
|
<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>
|
<h3>{t.checkout.shippingHintTitle}</h3>
|
||||||
<p class="small">{t.checkout.shippingHint}</p>
|
<p class="small">{t.checkout.shippingHint}</p>
|
||||||
<a class="small" href="/ch/versand-zahlung/">{t.checkout.shippingLink}</a>
|
<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 }}>
|
<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.
|
// 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>
|
||||||
<script type="module">
|
<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 { formatPrice } from "../../../i18n/format";
|
||||||
import { products } from "../../../data/products";
|
import { products } from "../../../data/products";
|
||||||
import { berechneVersandkosten } from "../../../data/versand";
|
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 summary = document.getElementById("checkout-summary");
|
||||||
const landSelect = document.getElementById("land");
|
const landSelect = document.getElementById("land");
|
||||||
@@ -105,17 +115,45 @@ const t = useTranslations(lang);
|
|||||||
function renderSummary() {
|
function renderSummary() {
|
||||||
const land = landSelect.value;
|
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 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 = `
|
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"><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();
|
renderSummary();
|
||||||
landSelect.addEventListener("change", 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) => {
|
document.getElementById("checkout-form").addEventListener("submit", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (cart.length === 0) {
|
if (cart.length === 0) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getCategory } from "../../../data/categories";
|
|||||||
import type { Locale } from "../../../i18n/config";
|
import type { Locale } from "../../../i18n/config";
|
||||||
import { useTranslations } from "../../../i18n/ui";
|
import { useTranslations } from "../../../i18n/ui";
|
||||||
import { formatPrice } from "../../../i18n/format";
|
import { formatPrice } from "../../../i18n/format";
|
||||||
|
import { effektiverPreis, rabattLabel, hatAktivenRabatt } from "../../../data/rabatt";
|
||||||
|
|
||||||
export function getStaticPaths() {
|
export function getStaticPaths() {
|
||||||
return products.map((p) => ({ params: { slug: p.slug } }));
|
return products.map((p) => ({ params: { slug: p.slug } }));
|
||||||
@@ -22,6 +23,10 @@ const name = product.name[lang];
|
|||||||
const desc = product.beschreibung[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 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][] = [
|
const felder: [string, string | undefined][] = [
|
||||||
[t.product.articleNo, product.artikelnummer],
|
[t.product.articleNo, product.artikelnummer],
|
||||||
@@ -68,21 +73,32 @@ const felder: [string, string | undefined][] = [
|
|||||||
<h1>{name}</h1>
|
<h1>{name}</h1>
|
||||||
<div class="price-row-detail">
|
<div class="price-row-detail">
|
||||||
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
|
{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>
|
</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="small">{t.common.vatNote} · {t.common.plusShipping}</p>
|
||||||
<p class="lead">{desc}</p>
|
<p class="lead">{desc}</p>
|
||||||
|
|
||||||
<div class="qty-row">
|
<div class="qty-row">
|
||||||
<label for="qty">{t.common.quantity}</label>
|
<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
|
<button
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
id="add-to-cart"
|
id="add-to-cart"
|
||||||
disabled={product.bestand === 0}
|
disabled={product.bestand === 0}
|
||||||
data-slug={product.slug}
|
data-slug={product.slug}
|
||||||
data-name={name}
|
data-name={name}
|
||||||
data-preis={product.preis}
|
data-preis={finalPrice}
|
||||||
>
|
>
|
||||||
{product.bestand === 0 ? t.common.soldOut : t.common.addToCart}
|
{product.bestand === 0 ? t.common.soldOut : t.common.addToCart}
|
||||||
</button>
|
</button>
|
||||||
@@ -120,6 +136,12 @@ const felder: [string, string | undefined][] = [
|
|||||||
const btn = document.getElementById("add-to-cart") as HTMLButtonElement | null;
|
const btn = document.getElementById("add-to-cart") as HTMLButtonElement | null;
|
||||||
const qtyInput = document.getElementById("qty") as HTMLInputElement;
|
const qtyInput = document.getElementById("qty") as HTMLInputElement;
|
||||||
const confirm = document.getElementById("add-confirm")!;
|
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", () => {
|
btn?.addEventListener("click", () => {
|
||||||
const { slug, name, preis } = btn.dataset;
|
const { slug, name, preis } = btn.dataset;
|
||||||
addToCart({ slug: slug!, name: name!, preis: Number(preis) }, Math.max(1, Number(qtyInput.value) || 1));
|
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 Layout from "../../../../layouts/Layout.astro";
|
||||||
|
import ProductCard from "../../../../components/ProductCard.astro";
|
||||||
import { categories, getCategory, getSubcategory } from "../../../../data/categories";
|
import { categories, getCategory, getSubcategory } from "../../../../data/categories";
|
||||||
|
import { productsByUnterkategorie } from "../../../../data/products";
|
||||||
import type { Locale } from "../../../../i18n/config";
|
import type { Locale } from "../../../../i18n/config";
|
||||||
import { useTranslations } from "../../../../i18n/ui";
|
import { useTranslations } from "../../../../i18n/ui";
|
||||||
|
|
||||||
@@ -15,8 +17,12 @@ const t = useTranslations(lang);
|
|||||||
const { kategorie, unterkategorie } = Astro.params;
|
const { kategorie, unterkategorie } = Astro.params;
|
||||||
const category = getCategory(kategorie!)!;
|
const category = getCategory(kategorie!)!;
|
||||||
const sub = getSubcategory(kategorie!, unterkategorie!)!;
|
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">
|
<div class="container">
|
||||||
<p class="small breadcrumb" style="margin-top:1.5rem;">
|
<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]}
|
<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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="section-tight">
|
{featured ? (
|
||||||
<div class="container">
|
<section class="section-tight">
|
||||||
{sub.vorstellung && (
|
<div class="container grid grid-2 intro">
|
||||||
<div class="character-intro">
|
<div class="portrait-frame portrait">
|
||||||
{sub.vorstellung[lang].split("\n").map((line) => <p>{line}</p>)}
|
{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 && (
|
{sub.mehrLink && (
|
||||||
<p class="character-more">
|
<p class="character-more">
|
||||||
Wosch meh über {sub.name[lang]} wüsse? Klick da:{" "}
|
Wosch meh über {sub.name[lang]} wüsse? Klick da:{" "}
|
||||||
@@ -41,8 +59,37 @@ const sub = getSubcategory(kategorie!, unterkategorie!)!;
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<p class="small">{t.shop.subcategoryEmpty}</p>
|
</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">
|
<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/${category.slug}/`}>← {category.name[lang]}</a>
|
||||||
<a class="btn btn-outline" href="/ch/shop/">{t.common.toShop}</a>
|
<a class="btn btn-outline" href="/ch/shop/">{t.common.toShop}</a>
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ const t = useTranslations(lang);
|
|||||||
<aside class="card cart-summary">
|
<aside class="card cart-summary">
|
||||||
<h3>🧾 {t.cart.subtotal}</h3>
|
<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"><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">
|
<div class="ship-country-row">
|
||||||
<label for="cart-land">📦 {t.checkout.country}</label>
|
<label for="cart-land">📦 {t.checkout.country}</label>
|
||||||
<select id="cart-land">
|
<select id="cart-land">
|
||||||
@@ -47,17 +56,18 @@ const t = useTranslations(lang);
|
|||||||
</section>
|
</section>
|
||||||
</Layout>
|
</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.
|
// 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>
|
||||||
<script type="module">
|
<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 { formatPrice } from "../../../i18n/format";
|
||||||
import { getProduct, products } from "../../../data/products";
|
import { getProduct, products } from "../../../data/products";
|
||||||
import { berechneVersandkosten } from "../../../data/versand";
|
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");
|
const landSelect = document.getElementById("cart-land");
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
@@ -80,19 +90,23 @@ const t = useTranslations(lang);
|
|||||||
const thumb = foto
|
const thumb = foto
|
||||||
? `<img class="thumb product-photo" src="${foto}" alt="${i.name}" loading="lazy" />`
|
? `<img class="thumb product-photo" src="${foto}" alt="${i.name}" loading="lazy" />`
|
||||||
: `<div class="img-placeholder thumb" role="img" aria-label="${i.name}"></div>`;
|
: `<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 `
|
return `
|
||||||
<div class="cart-item">
|
<div class="cart-item">
|
||||||
${thumb}
|
${thumb}
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<strong>${i.name}</strong>
|
<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>
|
||||||
<div class="qty-controls">
|
<div class="qty-controls">
|
||||||
<button data-action="dec" data-slug="${i.slug}" aria-label="−">−</button>
|
<button data-action="dec" data-slug="${i.slug}" aria-label="−">−</button>
|
||||||
<span>${i.menge}</span>
|
<span>${i.menge}</span>
|
||||||
<button data-action="inc" data-slug="${i.slug}" aria-label="+">+</button>
|
<button data-action="inc" data-slug="${i.slug}" aria-label="+">+</button>
|
||||||
</div>
|
</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>
|
<a href="#" class="remove" data-action="remove" data-slug="${i.slug}">✕ ${removeLabel}</a>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -101,12 +115,30 @@ const t = useTranslations(lang);
|
|||||||
const subtotal = cartTotal();
|
const subtotal = cartTotal();
|
||||||
const remaining = Math.max(0, freeShipFrom - subtotal);
|
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 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-subtotal").textContent = formatPrice(subtotal, cartLang);
|
||||||
document.getElementById("sum-shipping").textContent = shipping === 0 ? freeLabel : formatPrice(shipping, 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 =
|
document.getElementById("shipping-hint").textContent =
|
||||||
remaining > 0 ? remainingTemplate.replace("__V__", formatPrice(remaining, cartLang)) : `🩵 ${freeShippingText}`;
|
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) => {
|
itemsEl.querySelectorAll("button, a.remove").forEach((el) => {
|
||||||
el.addEventListener("click", (e) => {
|
el.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
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();
|
render();
|
||||||
window.addEventListener("cart:changed", render);
|
window.addEventListener("cart:changed", render);
|
||||||
landSelect.addEventListener("change", render);
|
landSelect.addEventListener("change", render);
|
||||||
|
|||||||
@@ -18,6 +18,26 @@ const t = useTranslations(lang);
|
|||||||
<section class="section-tight">
|
<section class="section-tight">
|
||||||
<div class="container checkout-layout">
|
<div class="container checkout-layout">
|
||||||
<form class="frm card" id="checkout-form">
|
<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>
|
<h3>{t.checkout.step1}</h3>
|
||||||
<div><label for="email">{t.checkout.email}</label><input id="email" type="email" required /></div>
|
<div><label for="email">{t.checkout.email}</label><input id="email" type="email" required /></div>
|
||||||
<div class="grid grid-2">
|
<div class="grid grid-2">
|
||||||
@@ -39,26 +59,6 @@ const t = useTranslations(lang);
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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" />
|
<hr class="divider" />
|
||||||
<h3>{t.checkout.step3}</h3>
|
<h3>{t.checkout.step3}</h3>
|
||||||
<div id="checkout-summary" class="checkout-summary"></div>
|
<div id="checkout-summary" class="checkout-summary"></div>
|
||||||
@@ -77,6 +77,15 @@ const t = useTranslations(lang);
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<aside class="card checkout-aside">
|
<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>
|
<h3>{t.checkout.shippingHintTitle}</h3>
|
||||||
<p class="small">{t.checkout.shippingHint}</p>
|
<p class="small">{t.checkout.shippingHint}</p>
|
||||||
<a class="small" href="/versand-zahlung/">{t.checkout.shippingLink}</a>
|
<a class="small" href="/versand-zahlung/">{t.checkout.shippingLink}</a>
|
||||||
@@ -85,20 +94,21 @@ const t = useTranslations(lang);
|
|||||||
</section>
|
</section>
|
||||||
</Layout>
|
</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
|
// 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
|
// `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,
|
// module" fehlschlagen. Deshalb hier nur die vom Server gerenderten Werte auf window ablegen,
|
||||||
// die eigentliche Logik läuft im separaten <script type="module"> darunter.
|
// 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>
|
||||||
<script type="module">
|
<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 { formatPrice } from "../../i18n/format";
|
||||||
import { products } from "../../data/products";
|
import { products } from "../../data/products";
|
||||||
import { berechneVersandkosten } from "../../data/versand";
|
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 summary = document.getElementById("checkout-summary");
|
||||||
const landSelect = document.getElementById("land");
|
const landSelect = document.getElementById("land");
|
||||||
@@ -108,17 +118,45 @@ const t = useTranslations(lang);
|
|||||||
function renderSummary() {
|
function renderSummary() {
|
||||||
const land = landSelect.value;
|
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 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 = `
|
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"><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();
|
renderSummary();
|
||||||
landSelect.addEventListener("change", 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) => {
|
document.getElementById("checkout-form").addEventListener("submit", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (cart.length === 0) {
|
if (cart.length === 0) {
|
||||||
|
|||||||
@@ -18,6 +18,26 @@ const t = useTranslations(lang);
|
|||||||
<section class="section-tight">
|
<section class="section-tight">
|
||||||
<div class="container checkout-layout">
|
<div class="container checkout-layout">
|
||||||
<form class="frm card" id="checkout-form">
|
<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>
|
<h3>{t.checkout.step1}</h3>
|
||||||
<div><label for="email">{t.checkout.email}</label><input id="email" type="email" required /></div>
|
<div><label for="email">{t.checkout.email}</label><input id="email" type="email" required /></div>
|
||||||
<div class="grid grid-2">
|
<div class="grid grid-2">
|
||||||
@@ -39,26 +59,6 @@ const t = useTranslations(lang);
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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" />
|
<hr class="divider" />
|
||||||
<h3>{t.checkout.step3}</h3>
|
<h3>{t.checkout.step3}</h3>
|
||||||
<div id="checkout-summary" class="checkout-summary"></div>
|
<div id="checkout-summary" class="checkout-summary"></div>
|
||||||
@@ -77,6 +77,15 @@ const t = useTranslations(lang);
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<aside class="card checkout-aside">
|
<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>
|
<h3>{t.checkout.shippingHintTitle}</h3>
|
||||||
<p class="small">{t.checkout.shippingHint}</p>
|
<p class="small">{t.checkout.shippingHint}</p>
|
||||||
<a class="small" href="/en/versand-zahlung/">{t.checkout.shippingLink}</a>
|
<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 }}>
|
<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.
|
// 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>
|
||||||
<script type="module">
|
<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 { formatPrice } from "../../../i18n/format";
|
||||||
import { products } from "../../../data/products";
|
import { products } from "../../../data/products";
|
||||||
import { berechneVersandkosten } from "../../../data/versand";
|
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 summary = document.getElementById("checkout-summary");
|
||||||
const landSelect = document.getElementById("land");
|
const landSelect = document.getElementById("land");
|
||||||
@@ -105,17 +115,45 @@ const t = useTranslations(lang);
|
|||||||
function renderSummary() {
|
function renderSummary() {
|
||||||
const land = landSelect.value;
|
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 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 = `
|
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"><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();
|
renderSummary();
|
||||||
landSelect.addEventListener("change", 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) => {
|
document.getElementById("checkout-form").addEventListener("submit", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (cart.length === 0) {
|
if (cart.length === 0) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getCategory } from "../../../data/categories";
|
|||||||
import type { Locale } from "../../../i18n/config";
|
import type { Locale } from "../../../i18n/config";
|
||||||
import { useTranslations } from "../../../i18n/ui";
|
import { useTranslations } from "../../../i18n/ui";
|
||||||
import { formatPrice } from "../../../i18n/format";
|
import { formatPrice } from "../../../i18n/format";
|
||||||
|
import { effektiverPreis, rabattLabel, hatAktivenRabatt } from "../../../data/rabatt";
|
||||||
|
|
||||||
export function getStaticPaths() {
|
export function getStaticPaths() {
|
||||||
return products.map((p) => ({ params: { slug: p.slug } }));
|
return products.map((p) => ({ params: { slug: p.slug } }));
|
||||||
@@ -22,6 +23,10 @@ const name = product.name[lang];
|
|||||||
const desc = product.beschreibung[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 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][] = [
|
const felder: [string, string | undefined][] = [
|
||||||
[t.product.articleNo, product.artikelnummer],
|
[t.product.articleNo, product.artikelnummer],
|
||||||
@@ -68,21 +73,32 @@ const felder: [string, string | undefined][] = [
|
|||||||
<h1>{name}</h1>
|
<h1>{name}</h1>
|
||||||
<div class="price-row-detail">
|
<div class="price-row-detail">
|
||||||
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
|
{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>
|
</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="small">{t.common.vatNote} · {t.common.plusShipping}</p>
|
||||||
<p class="lead">{desc}</p>
|
<p class="lead">{desc}</p>
|
||||||
|
|
||||||
<div class="qty-row">
|
<div class="qty-row">
|
||||||
<label for="qty">{t.common.quantity}</label>
|
<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
|
<button
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
id="add-to-cart"
|
id="add-to-cart"
|
||||||
disabled={product.bestand === 0}
|
disabled={product.bestand === 0}
|
||||||
data-slug={product.slug}
|
data-slug={product.slug}
|
||||||
data-name={name}
|
data-name={name}
|
||||||
data-preis={product.preis}
|
data-preis={finalPrice}
|
||||||
>
|
>
|
||||||
{product.bestand === 0 ? t.common.soldOut : t.common.addToCart}
|
{product.bestand === 0 ? t.common.soldOut : t.common.addToCart}
|
||||||
</button>
|
</button>
|
||||||
@@ -120,6 +136,12 @@ const felder: [string, string | undefined][] = [
|
|||||||
const btn = document.getElementById("add-to-cart") as HTMLButtonElement | null;
|
const btn = document.getElementById("add-to-cart") as HTMLButtonElement | null;
|
||||||
const qtyInput = document.getElementById("qty") as HTMLInputElement;
|
const qtyInput = document.getElementById("qty") as HTMLInputElement;
|
||||||
const confirm = document.getElementById("add-confirm")!;
|
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", () => {
|
btn?.addEventListener("click", () => {
|
||||||
const { slug, name, preis } = btn.dataset;
|
const { slug, name, preis } = btn.dataset;
|
||||||
addToCart({ slug: slug!, name: name!, preis: Number(preis) }, Math.max(1, Number(qtyInput.value) || 1));
|
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 Layout from "../../../../layouts/Layout.astro";
|
||||||
|
import ProductCard from "../../../../components/ProductCard.astro";
|
||||||
import { categories, getCategory, getSubcategory } from "../../../../data/categories";
|
import { categories, getCategory, getSubcategory } from "../../../../data/categories";
|
||||||
|
import { productsByUnterkategorie } from "../../../../data/products";
|
||||||
import type { Locale } from "../../../../i18n/config";
|
import type { Locale } from "../../../../i18n/config";
|
||||||
import { useTranslations } from "../../../../i18n/ui";
|
import { useTranslations } from "../../../../i18n/ui";
|
||||||
|
|
||||||
@@ -15,6 +17,10 @@ const t = useTranslations(lang);
|
|||||||
const { kategorie, unterkategorie } = Astro.params;
|
const { kategorie, unterkategorie } = Astro.params;
|
||||||
const category = getCategory(kategorie!)!;
|
const category = getCategory(kategorie!)!;
|
||||||
const sub = getSubcategory(kategorie!, unterkategorie!)!;
|
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}/`}>
|
<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">
|
<div class="container">
|
||||||
@@ -29,11 +35,23 @@ const sub = getSubcategory(kategorie!, unterkategorie!)!;
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="section-tight">
|
{featured ? (
|
||||||
<div class="container">
|
<section class="section-tight">
|
||||||
{sub.vorstellung && (
|
<div class="container grid grid-2 intro">
|
||||||
<div class="character-intro">
|
<div class="portrait-frame portrait">
|
||||||
{sub.vorstellung[lang].split("\n").map((line) => <p>{line}</p>)}
|
{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 && (
|
{sub.mehrLink && (
|
||||||
<p class="character-more">
|
<p class="character-more">
|
||||||
Want to learn more about {sub.name[lang]}? Click here:{" "}
|
Want to learn more about {sub.name[lang]}? Click here:{" "}
|
||||||
@@ -41,8 +59,37 @@ const sub = getSubcategory(kategorie!, unterkategorie!)!;
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<p class="small">{t.shop.subcategoryEmpty}</p>
|
</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">
|
<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/${category.slug}/`}>← {category.name[lang]}</a>
|
||||||
<a class="btn btn-outline" href="/en/shop/">{t.common.toShop}</a>
|
<a class="btn btn-outline" href="/en/shop/">{t.common.toShop}</a>
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ const t = useTranslations(lang);
|
|||||||
<aside class="card cart-summary">
|
<aside class="card cart-summary">
|
||||||
<h3>🧾 {t.cart.subtotal}</h3>
|
<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"><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">
|
<div class="ship-country-row">
|
||||||
<label for="cart-land">📦 {t.checkout.country}</label>
|
<label for="cart-land">📦 {t.checkout.country}</label>
|
||||||
<select id="cart-land">
|
<select id="cart-land">
|
||||||
@@ -47,17 +56,18 @@ const t = useTranslations(lang);
|
|||||||
</section>
|
</section>
|
||||||
</Layout>
|
</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.
|
// 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>
|
||||||
<script type="module">
|
<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 { formatPrice } from "../../../i18n/format";
|
||||||
import { getProduct, products } from "../../../data/products";
|
import { getProduct, products } from "../../../data/products";
|
||||||
import { berechneVersandkosten } from "../../../data/versand";
|
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");
|
const landSelect = document.getElementById("cart-land");
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
@@ -80,19 +90,23 @@ const t = useTranslations(lang);
|
|||||||
const thumb = foto
|
const thumb = foto
|
||||||
? `<img class="thumb product-photo" src="${foto}" alt="${i.name}" loading="lazy" />`
|
? `<img class="thumb product-photo" src="${foto}" alt="${i.name}" loading="lazy" />`
|
||||||
: `<div class="img-placeholder thumb" role="img" aria-label="${i.name}"></div>`;
|
: `<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 `
|
return `
|
||||||
<div class="cart-item">
|
<div class="cart-item">
|
||||||
${thumb}
|
${thumb}
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<strong>${i.name}</strong>
|
<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>
|
||||||
<div class="qty-controls">
|
<div class="qty-controls">
|
||||||
<button data-action="dec" data-slug="${i.slug}" aria-label="−">−</button>
|
<button data-action="dec" data-slug="${i.slug}" aria-label="−">−</button>
|
||||||
<span>${i.menge}</span>
|
<span>${i.menge}</span>
|
||||||
<button data-action="inc" data-slug="${i.slug}" aria-label="+">+</button>
|
<button data-action="inc" data-slug="${i.slug}" aria-label="+">+</button>
|
||||||
</div>
|
</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>
|
<a href="#" class="remove" data-action="remove" data-slug="${i.slug}">✕ ${removeLabel}</a>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -101,12 +115,30 @@ const t = useTranslations(lang);
|
|||||||
const subtotal = cartTotal();
|
const subtotal = cartTotal();
|
||||||
const remaining = Math.max(0, freeShipFrom - subtotal);
|
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 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-subtotal").textContent = formatPrice(subtotal, cartLang);
|
||||||
document.getElementById("sum-shipping").textContent = shipping === 0 ? freeLabel : formatPrice(shipping, 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 =
|
document.getElementById("shipping-hint").textContent =
|
||||||
remaining > 0 ? remainingTemplate.replace("__V__", formatPrice(remaining, cartLang)) : `🩵 ${freeShippingText}`;
|
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) => {
|
itemsEl.querySelectorAll("button, a.remove").forEach((el) => {
|
||||||
el.addEventListener("click", (e) => {
|
el.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
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();
|
render();
|
||||||
window.addEventListener("cart:changed", render);
|
window.addEventListener("cart:changed", render);
|
||||||
landSelect.addEventListener("change", render);
|
landSelect.addEventListener("change", render);
|
||||||
|
|||||||
@@ -18,6 +18,26 @@ const t = useTranslations(lang);
|
|||||||
<section class="section-tight">
|
<section class="section-tight">
|
||||||
<div class="container checkout-layout">
|
<div class="container checkout-layout">
|
||||||
<form class="frm card" id="checkout-form">
|
<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>
|
<h3>{t.checkout.step1}</h3>
|
||||||
<div><label for="email">{t.checkout.email}</label><input id="email" type="email" required /></div>
|
<div><label for="email">{t.checkout.email}</label><input id="email" type="email" required /></div>
|
||||||
<div class="grid grid-2">
|
<div class="grid grid-2">
|
||||||
@@ -39,26 +59,6 @@ const t = useTranslations(lang);
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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" />
|
<hr class="divider" />
|
||||||
<h3>{t.checkout.step3}</h3>
|
<h3>{t.checkout.step3}</h3>
|
||||||
<div id="checkout-summary" class="checkout-summary"></div>
|
<div id="checkout-summary" class="checkout-summary"></div>
|
||||||
@@ -77,6 +77,15 @@ const t = useTranslations(lang);
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<aside class="card checkout-aside">
|
<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>
|
<h3>{t.checkout.shippingHintTitle}</h3>
|
||||||
<p class="small">{t.checkout.shippingHint}</p>
|
<p class="small">{t.checkout.shippingHint}</p>
|
||||||
<a class="small" href="/fr/versand-zahlung/">{t.checkout.shippingLink}</a>
|
<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 }}>
|
<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.
|
// 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>
|
||||||
<script type="module">
|
<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 { formatPrice } from "../../../i18n/format";
|
||||||
import { products } from "../../../data/products";
|
import { products } from "../../../data/products";
|
||||||
import { berechneVersandkosten } from "../../../data/versand";
|
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 summary = document.getElementById("checkout-summary");
|
||||||
const landSelect = document.getElementById("land");
|
const landSelect = document.getElementById("land");
|
||||||
@@ -105,17 +115,45 @@ const t = useTranslations(lang);
|
|||||||
function renderSummary() {
|
function renderSummary() {
|
||||||
const land = landSelect.value;
|
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 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 = `
|
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"><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();
|
renderSummary();
|
||||||
landSelect.addEventListener("change", 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) => {
|
document.getElementById("checkout-form").addEventListener("submit", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (cart.length === 0) {
|
if (cart.length === 0) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getCategory } from "../../../data/categories";
|
|||||||
import type { Locale } from "../../../i18n/config";
|
import type { Locale } from "../../../i18n/config";
|
||||||
import { useTranslations } from "../../../i18n/ui";
|
import { useTranslations } from "../../../i18n/ui";
|
||||||
import { formatPrice } from "../../../i18n/format";
|
import { formatPrice } from "../../../i18n/format";
|
||||||
|
import { effektiverPreis, rabattLabel, hatAktivenRabatt } from "../../../data/rabatt";
|
||||||
|
|
||||||
export function getStaticPaths() {
|
export function getStaticPaths() {
|
||||||
return products.map((p) => ({ params: { slug: p.slug } }));
|
return products.map((p) => ({ params: { slug: p.slug } }));
|
||||||
@@ -22,6 +23,10 @@ const name = product.name[lang];
|
|||||||
const desc = product.beschreibung[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 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][] = [
|
const felder: [string, string | undefined][] = [
|
||||||
[t.product.articleNo, product.artikelnummer],
|
[t.product.articleNo, product.artikelnummer],
|
||||||
@@ -68,21 +73,32 @@ const felder: [string, string | undefined][] = [
|
|||||||
<h1>{name}</h1>
|
<h1>{name}</h1>
|
||||||
<div class="price-row-detail">
|
<div class="price-row-detail">
|
||||||
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
|
{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>
|
</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="small">{t.common.vatNote} · {t.common.plusShipping}</p>
|
||||||
<p class="lead">{desc}</p>
|
<p class="lead">{desc}</p>
|
||||||
|
|
||||||
<div class="qty-row">
|
<div class="qty-row">
|
||||||
<label for="qty">{t.common.quantity}</label>
|
<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
|
<button
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
id="add-to-cart"
|
id="add-to-cart"
|
||||||
disabled={product.bestand === 0}
|
disabled={product.bestand === 0}
|
||||||
data-slug={product.slug}
|
data-slug={product.slug}
|
||||||
data-name={name}
|
data-name={name}
|
||||||
data-preis={product.preis}
|
data-preis={finalPrice}
|
||||||
>
|
>
|
||||||
{product.bestand === 0 ? t.common.soldOut : t.common.addToCart}
|
{product.bestand === 0 ? t.common.soldOut : t.common.addToCart}
|
||||||
</button>
|
</button>
|
||||||
@@ -120,6 +136,12 @@ const felder: [string, string | undefined][] = [
|
|||||||
const btn = document.getElementById("add-to-cart") as HTMLButtonElement | null;
|
const btn = document.getElementById("add-to-cart") as HTMLButtonElement | null;
|
||||||
const qtyInput = document.getElementById("qty") as HTMLInputElement;
|
const qtyInput = document.getElementById("qty") as HTMLInputElement;
|
||||||
const confirm = document.getElementById("add-confirm")!;
|
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", () => {
|
btn?.addEventListener("click", () => {
|
||||||
const { slug, name, preis } = btn.dataset;
|
const { slug, name, preis } = btn.dataset;
|
||||||
addToCart({ slug: slug!, name: name!, preis: Number(preis) }, Math.max(1, Number(qtyInput.value) || 1));
|
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 Layout from "../../../../layouts/Layout.astro";
|
||||||
|
import ProductCard from "../../../../components/ProductCard.astro";
|
||||||
import { categories, getCategory, getSubcategory } from "../../../../data/categories";
|
import { categories, getCategory, getSubcategory } from "../../../../data/categories";
|
||||||
|
import { productsByUnterkategorie } from "../../../../data/products";
|
||||||
import type { Locale } from "../../../../i18n/config";
|
import type { Locale } from "../../../../i18n/config";
|
||||||
import { useTranslations } from "../../../../i18n/ui";
|
import { useTranslations } from "../../../../i18n/ui";
|
||||||
|
|
||||||
@@ -15,6 +17,10 @@ const t = useTranslations(lang);
|
|||||||
const { kategorie, unterkategorie } = Astro.params;
|
const { kategorie, unterkategorie } = Astro.params;
|
||||||
const category = getCategory(kategorie!)!;
|
const category = getCategory(kategorie!)!;
|
||||||
const sub = getSubcategory(kategorie!, unterkategorie!)!;
|
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}/`}>
|
<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">
|
<div class="container">
|
||||||
@@ -29,11 +35,23 @@ const sub = getSubcategory(kategorie!, unterkategorie!)!;
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="section-tight">
|
{featured ? (
|
||||||
<div class="container">
|
<section class="section-tight">
|
||||||
{sub.vorstellung && (
|
<div class="container grid grid-2 intro">
|
||||||
<div class="character-intro">
|
<div class="portrait-frame portrait">
|
||||||
{sub.vorstellung[lang].split("\n").map((line) => <p>{line}</p>)}
|
{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 && (
|
{sub.mehrLink && (
|
||||||
<p class="character-more">
|
<p class="character-more">
|
||||||
Tu veux en savoir plus sur {sub.name[lang]} ? Clique ici :{" "}
|
Tu veux en savoir plus sur {sub.name[lang]} ? Clique ici :{" "}
|
||||||
@@ -41,8 +59,37 @@ const sub = getSubcategory(kategorie!, unterkategorie!)!;
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<p class="small">{t.shop.subcategoryEmpty}</p>
|
</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">
|
<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/${category.slug}/`}>← {category.name[lang]}</a>
|
||||||
<a class="btn btn-outline" href="/fr/shop/">{t.common.toShop}</a>
|
<a class="btn btn-outline" href="/fr/shop/">{t.common.toShop}</a>
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ const t = useTranslations(lang);
|
|||||||
<aside class="card cart-summary">
|
<aside class="card cart-summary">
|
||||||
<h3>🧾 {t.cart.subtotal}</h3>
|
<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"><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">
|
<div class="ship-country-row">
|
||||||
<label for="cart-land">📦 {t.checkout.country}</label>
|
<label for="cart-land">📦 {t.checkout.country}</label>
|
||||||
<select id="cart-land">
|
<select id="cart-land">
|
||||||
@@ -47,17 +56,18 @@ const t = useTranslations(lang);
|
|||||||
</section>
|
</section>
|
||||||
</Layout>
|
</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.
|
// 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>
|
||||||
<script type="module">
|
<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 { formatPrice } from "../../../i18n/format";
|
||||||
import { getProduct, products } from "../../../data/products";
|
import { getProduct, products } from "../../../data/products";
|
||||||
import { berechneVersandkosten } from "../../../data/versand";
|
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");
|
const landSelect = document.getElementById("cart-land");
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
@@ -80,19 +90,23 @@ const t = useTranslations(lang);
|
|||||||
const thumb = foto
|
const thumb = foto
|
||||||
? `<img class="thumb product-photo" src="${foto}" alt="${i.name}" loading="lazy" />`
|
? `<img class="thumb product-photo" src="${foto}" alt="${i.name}" loading="lazy" />`
|
||||||
: `<div class="img-placeholder thumb" role="img" aria-label="${i.name}"></div>`;
|
: `<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 `
|
return `
|
||||||
<div class="cart-item">
|
<div class="cart-item">
|
||||||
${thumb}
|
${thumb}
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<strong>${i.name}</strong>
|
<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>
|
||||||
<div class="qty-controls">
|
<div class="qty-controls">
|
||||||
<button data-action="dec" data-slug="${i.slug}" aria-label="−">−</button>
|
<button data-action="dec" data-slug="${i.slug}" aria-label="−">−</button>
|
||||||
<span>${i.menge}</span>
|
<span>${i.menge}</span>
|
||||||
<button data-action="inc" data-slug="${i.slug}" aria-label="+">+</button>
|
<button data-action="inc" data-slug="${i.slug}" aria-label="+">+</button>
|
||||||
</div>
|
</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>
|
<a href="#" class="remove" data-action="remove" data-slug="${i.slug}">✕ ${removeLabel}</a>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -101,12 +115,30 @@ const t = useTranslations(lang);
|
|||||||
const subtotal = cartTotal();
|
const subtotal = cartTotal();
|
||||||
const remaining = Math.max(0, freeShipFrom - subtotal);
|
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 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-subtotal").textContent = formatPrice(subtotal, cartLang);
|
||||||
document.getElementById("sum-shipping").textContent = shipping === 0 ? freeLabel : formatPrice(shipping, 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 =
|
document.getElementById("shipping-hint").textContent =
|
||||||
remaining > 0 ? remainingTemplate.replace("__V__", formatPrice(remaining, cartLang)) : `🩵 ${freeShippingText}`;
|
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) => {
|
itemsEl.querySelectorAll("button, a.remove").forEach((el) => {
|
||||||
el.addEventListener("click", (e) => {
|
el.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
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();
|
render();
|
||||||
window.addEventListener("cart:changed", render);
|
window.addEventListener("cart:changed", render);
|
||||||
landSelect.addEventListener("change", render);
|
landSelect.addEventListener("change", render);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getCategory } from "../../data/categories";
|
|||||||
import type { Locale } from "../../i18n/config";
|
import type { Locale } from "../../i18n/config";
|
||||||
import { useTranslations } from "../../i18n/ui";
|
import { useTranslations } from "../../i18n/ui";
|
||||||
import { formatPrice } from "../../i18n/format";
|
import { formatPrice } from "../../i18n/format";
|
||||||
|
import { effektiverPreis, rabattLabel, hatAktivenRabatt } from "../../data/rabatt";
|
||||||
|
|
||||||
export function getStaticPaths() {
|
export function getStaticPaths() {
|
||||||
return products.map((p) => ({ params: { slug: p.slug } }));
|
return products.map((p) => ({ params: { slug: p.slug } }));
|
||||||
@@ -22,6 +23,10 @@ const name = product.name[lang];
|
|||||||
const desc = product.beschreibung[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 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][] = [
|
const felder: [string, string | undefined][] = [
|
||||||
[t.product.articleNo, product.artikelnummer],
|
[t.product.articleNo, product.artikelnummer],
|
||||||
@@ -68,21 +73,32 @@ const felder: [string, string | undefined][] = [
|
|||||||
<h1>{name}</h1>
|
<h1>{name}</h1>
|
||||||
<div class="price-row-detail">
|
<div class="price-row-detail">
|
||||||
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
|
{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>
|
</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="small">{t.common.vatNote} · {t.common.plusShipping}</p>
|
||||||
<p class="lead">{desc}</p>
|
<p class="lead">{desc}</p>
|
||||||
|
|
||||||
<div class="qty-row">
|
<div class="qty-row">
|
||||||
<label for="qty">{t.common.quantity}</label>
|
<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
|
<button
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
id="add-to-cart"
|
id="add-to-cart"
|
||||||
disabled={product.bestand === 0}
|
disabled={product.bestand === 0}
|
||||||
data-slug={product.slug}
|
data-slug={product.slug}
|
||||||
data-name={name}
|
data-name={name}
|
||||||
data-preis={product.preis}
|
data-preis={finalPrice}
|
||||||
>
|
>
|
||||||
{product.bestand === 0 ? t.common.soldOut : t.common.addToCart}
|
{product.bestand === 0 ? t.common.soldOut : t.common.addToCart}
|
||||||
</button>
|
</button>
|
||||||
@@ -120,6 +136,12 @@ const felder: [string, string | undefined][] = [
|
|||||||
const btn = document.getElementById("add-to-cart") as HTMLButtonElement | null;
|
const btn = document.getElementById("add-to-cart") as HTMLButtonElement | null;
|
||||||
const qtyInput = document.getElementById("qty") as HTMLInputElement;
|
const qtyInput = document.getElementById("qty") as HTMLInputElement;
|
||||||
const confirm = document.getElementById("add-confirm")!;
|
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", () => {
|
btn?.addEventListener("click", () => {
|
||||||
const { slug, name, preis } = btn.dataset;
|
const { slug, name, preis } = btn.dataset;
|
||||||
addToCart({ slug: slug!, name: name!, preis: Number(preis) }, Math.max(1, Number(qtyInput.value) || 1));
|
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 Layout from "../../../layouts/Layout.astro";
|
||||||
|
import ProductCard from "../../../components/ProductCard.astro";
|
||||||
import { categories, getCategory, getSubcategory } from "../../../data/categories";
|
import { categories, getCategory, getSubcategory } from "../../../data/categories";
|
||||||
|
import { productsByUnterkategorie } from "../../../data/products";
|
||||||
import type { Locale } from "../../../i18n/config";
|
import type { Locale } from "../../../i18n/config";
|
||||||
import { useTranslations } from "../../../i18n/ui";
|
import { useTranslations } from "../../../i18n/ui";
|
||||||
|
|
||||||
@@ -15,6 +17,13 @@ const t = useTranslations(lang);
|
|||||||
const { kategorie, unterkategorie } = Astro.params;
|
const { kategorie, unterkategorie } = Astro.params;
|
||||||
const category = getCategory(kategorie!)!;
|
const category = getCategory(kategorie!)!;
|
||||||
const sub = getSubcategory(kategorie!, unterkategorie!)!;
|
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}/`}>
|
<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">
|
<div class="container">
|
||||||
@@ -29,11 +38,23 @@ const sub = getSubcategory(kategorie!, unterkategorie!)!;
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="section-tight">
|
{featured ? (
|
||||||
<div class="container">
|
<section class="section-tight">
|
||||||
{sub.vorstellung && (
|
<div class="container grid grid-2 intro">
|
||||||
<div class="character-intro">
|
<div class="portrait-frame portrait">
|
||||||
{sub.vorstellung[lang].split("\n").map((line) => <p>{line}</p>)}
|
{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 && (
|
{sub.mehrLink && (
|
||||||
<p class="character-more">
|
<p class="character-more">
|
||||||
Du willst mehr über {sub.name[lang]} erfahren, dann klicke hier:{" "}
|
Du willst mehr über {sub.name[lang]} erfahren, dann klicke hier:{" "}
|
||||||
@@ -41,8 +62,37 @@ const sub = getSubcategory(kategorie!, unterkategorie!)!;
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<p class="small">{t.shop.subcategoryEmpty}</p>
|
</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">
|
<div class="btn-row">
|
||||||
<a class="btn btn-outline" href={`/shop/${category.slug}/`}>← {category.name[lang]}</a>
|
<a class="btn btn-outline" href={`/shop/${category.slug}/`}>← {category.name[lang]}</a>
|
||||||
<a class="btn btn-outline" href="/shop/">{t.common.toShop}</a>
|
<a class="btn btn-outline" href="/shop/">{t.common.toShop}</a>
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ const t = useTranslations(lang);
|
|||||||
<aside class="card cart-summary">
|
<aside class="card cart-summary">
|
||||||
<h3>🧾 {t.cart.subtotal}</h3>
|
<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"><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">
|
<div class="ship-country-row">
|
||||||
<label for="cart-land">📦 {t.checkout.country}</label>
|
<label for="cart-land">📦 {t.checkout.country}</label>
|
||||||
<select id="cart-land">
|
<select id="cart-land">
|
||||||
@@ -47,17 +56,18 @@ const t = useTranslations(lang);
|
|||||||
</section>
|
</section>
|
||||||
</Layout>
|
</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.
|
// 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>
|
||||||
<script type="module">
|
<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 { formatPrice } from "../../i18n/format";
|
||||||
import { getProduct, products } from "../../data/products";
|
import { getProduct, products } from "../../data/products";
|
||||||
import { berechneVersandkosten } from "../../data/versand";
|
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");
|
const landSelect = document.getElementById("cart-land");
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
@@ -80,19 +90,23 @@ const t = useTranslations(lang);
|
|||||||
const thumb = foto
|
const thumb = foto
|
||||||
? `<img class="thumb product-photo" src="${foto}" alt="${i.name}" loading="lazy" />`
|
? `<img class="thumb product-photo" src="${foto}" alt="${i.name}" loading="lazy" />`
|
||||||
: `<div class="img-placeholder thumb" role="img" aria-label="${i.name}"></div>`;
|
: `<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 `
|
return `
|
||||||
<div class="cart-item">
|
<div class="cart-item">
|
||||||
${thumb}
|
${thumb}
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<strong>${i.name}</strong>
|
<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>
|
||||||
<div class="qty-controls">
|
<div class="qty-controls">
|
||||||
<button data-action="dec" data-slug="${i.slug}" aria-label="−">−</button>
|
<button data-action="dec" data-slug="${i.slug}" aria-label="−">−</button>
|
||||||
<span>${i.menge}</span>
|
<span>${i.menge}</span>
|
||||||
<button data-action="inc" data-slug="${i.slug}" aria-label="+">+</button>
|
<button data-action="inc" data-slug="${i.slug}" aria-label="+">+</button>
|
||||||
</div>
|
</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>
|
<a href="#" class="remove" data-action="remove" data-slug="${i.slug}">✕ ${removeLabel}</a>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -101,12 +115,30 @@ const t = useTranslations(lang);
|
|||||||
const subtotal = cartTotal();
|
const subtotal = cartTotal();
|
||||||
const remaining = Math.max(0, freeShipFrom - subtotal);
|
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 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-subtotal").textContent = formatPrice(subtotal, cartLang);
|
||||||
document.getElementById("sum-shipping").textContent = shipping === 0 ? freeLabel : formatPrice(shipping, 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 =
|
document.getElementById("shipping-hint").textContent =
|
||||||
remaining > 0 ? remainingTemplate.replace("__V__", formatPrice(remaining, cartLang)) : `🩵 ${freeShippingText}`;
|
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) => {
|
itemsEl.querySelectorAll("button, a.remove").forEach((el) => {
|
||||||
el.addEventListener("click", (e) => {
|
el.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
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();
|
render();
|
||||||
window.addEventListener("cart:changed", render);
|
window.addEventListener("cart:changed", render);
|
||||||
landSelect.addEventListener("change", render);
|
landSelect.addEventListener("change", render);
|
||||||
|
|||||||
+41
-1
@@ -1,6 +1,10 @@
|
|||||||
// Rein clientseitiger Warenkorb (Phase 1, kein Backend). Speichert im localStorage.
|
// Rein clientseitiger Warenkorb (Phase 1, kein Backend). Speichert im localStorage.
|
||||||
// TODO Phase 2: durch echten Cloudflare-Worker-Warenkorb/Checkout ersetzen.
|
// TODO Phase 2: durch echten Cloudflare-Worker-Warenkorb/Checkout ersetzen.
|
||||||
|
|
||||||
|
import { getProduct } from "../data/products";
|
||||||
|
import { effektiverZeilenpreis } from "../data/rabatt";
|
||||||
|
import { findeGutschein, gutscheinRabatt, type Gutschein } from "../data/gutscheine";
|
||||||
|
|
||||||
export interface CartItem {
|
export interface CartItem {
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -54,6 +58,42 @@ export function cartCount(): number {
|
|||||||
return getCart().reduce((sum, i) => sum + i.menge, 0);
|
return getCart().reduce((sum, i) => sum + i.menge, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zwischensumme inkl. Produkt-Rabatt (Prozent/Betrag) UND Mengenrabatt-Staffel — schaut sich
|
||||||
|
// dafür live die aktuellen Produktdaten an (nicht den bei addToCart gespeicherten Preis), damit
|
||||||
|
// ein nachträglich geänderter/abgelaufener Rabatt sich sofort korrekt auswirkt.
|
||||||
export function cartTotal(): number {
|
export function cartTotal(): number {
|
||||||
return getCart().reduce((sum, i) => sum + i.menge * i.preis, 0);
|
return getCart().reduce((sum, i) => {
|
||||||
|
const produkt = getProduct(i.slug);
|
||||||
|
return sum + (produkt ? effektiverZeilenpreis(produkt, i.menge) : i.menge * i.preis);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const COUPON_KEY = "vandiy_coupon_v1";
|
||||||
|
|
||||||
|
export function getCouponCode(): string {
|
||||||
|
if (typeof localStorage === "undefined") return "";
|
||||||
|
return localStorage.getItem(COUPON_KEY) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCouponCode(code: string) {
|
||||||
|
localStorage.setItem(COUPON_KEY, code.trim());
|
||||||
|
window.dispatchEvent(new CustomEvent("cart:changed", { detail: getCart() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCoupon() {
|
||||||
|
localStorage.removeItem(COUPON_KEY);
|
||||||
|
window.dispatchEvent(new CustomEvent("cart:changed", { detail: getCart() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der aktuell eingelöste Gutschein, falls der gespeicherte Code (noch) gültig ist. */
|
||||||
|
export function appliedCoupon(): Gutschein | undefined {
|
||||||
|
const code = getCouponCode();
|
||||||
|
if (!code) return undefined;
|
||||||
|
return findeGutschein(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rabatt-Betrag in Euro, den der eingelöste Gutschein auf die gegebene Zwischensumme gibt. */
|
||||||
|
export function couponDiscount(subtotal: number): number {
|
||||||
|
const g = appliedCoupon();
|
||||||
|
return g ? gutscheinRabatt(g, subtotal) : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-10
@@ -768,10 +768,57 @@ a:focus-visible, button:focus-visible {
|
|||||||
}
|
}
|
||||||
.thumb.product-photo:hover { box-shadow: 0 0 0 3px rgba(10, 9, 16, 0.6), 0 0 0 4px var(--c-accent); }
|
.thumb.product-photo:hover { box-shadow: 0 0 0 3px rgba(10, 9, 16, 0.6), 0 0 0 4px var(--c-accent); }
|
||||||
.badge-row { display: flex; gap: 0.5rem; margin-bottom: 0.8rem; flex-wrap: wrap; }
|
.badge-row { display: flex; gap: 0.5rem; margin-bottom: 0.8rem; flex-wrap: wrap; }
|
||||||
.price-row-detail { display: flex; align-items: baseline; gap: 0.8rem; margin-bottom: 0.2rem; }
|
.price-row-detail { display: flex; align-items: baseline; gap: 0.8rem; margin-bottom: 0.2rem; flex-wrap: wrap; }
|
||||||
.price-detail { font-size: 1.8rem; font-weight: 700; color: var(--c-accent); }
|
.price-detail { font-size: 1.8rem; font-weight: 700; color: var(--c-accent); }
|
||||||
.qty-row { display: flex; align-items: center; gap: 0.8rem; margin: 1.4rem 0 0.5rem; }
|
.badge-rabatt { background: #ff5f7e; color: #fff; border-color: #ff5f7e; }
|
||||||
.qty-row input { width: 4.5rem; padding: 0.6em; border-radius: var(--radius-s); border: 1px solid rgba(244,241,247,0.15); background: var(--c-anthracite); color: var(--c-text); }
|
.mengenrabatt-list { list-style: none; margin: 0 0 1rem; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5em; }
|
||||||
|
.mengenrabatt-list li {
|
||||||
|
background: color-mix(in srgb, #ff5f7e 14%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, #ff5f7e 35%, transparent);
|
||||||
|
color: #ff5f7e;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
padding: 0.25em 0.6em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.qty-row { display: flex; align-items: center; gap: 1rem; margin: 1.4rem 0 0.5rem; }
|
||||||
|
|
||||||
|
/* Große, leicht zu treffende Mengen-Steuerung auf der Produktseite (ersetzt die winzigen
|
||||||
|
Browser-Standard-Pfeile eines <input type="number">). */
|
||||||
|
.qty-stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid rgba(244, 241, 247, 0.16);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--c-anthracite);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.qty-stepper button {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--c-text);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s var(--ease), color 0.15s var(--ease);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.qty-stepper button:hover { background: color-mix(in srgb, var(--c-accent) 18%, transparent); color: var(--c-accent); }
|
||||||
|
.qty-stepper input {
|
||||||
|
width: 3rem;
|
||||||
|
height: 44px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--c-text);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
.qty-stepper input::-webkit-outer-spin-button,
|
||||||
|
.qty-stepper input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||||
|
.qty-stepper input:focus { outline: none; }
|
||||||
.specs { margin-top: 1.5rem; }
|
.specs { margin-top: 1.5rem; }
|
||||||
.specs th { width: 40%; color: var(--c-text-muted); font-weight: 500; font-family: var(--font-body); }
|
.specs th { width: 40%; color: var(--c-text-muted); font-weight: 500; font-family: var(--font-body); }
|
||||||
|
|
||||||
@@ -874,6 +921,26 @@ a:focus-visible, button:focus-visible {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
.discount-row { color: #ff5f7e; font-weight: 600; }
|
||||||
|
.mengenrabatt-badge { color: #ff5f7e; font-weight: 600; margin-top: 0.15rem; }
|
||||||
|
.coupon-row { margin: 0.7rem 0; }
|
||||||
|
.coupon-row label { display: block; font-size: 0.85rem; color: var(--c-text-muted); margin-bottom: 0.35em; }
|
||||||
|
.coupon-input-group { display: flex; gap: 0.5em; }
|
||||||
|
.coupon-input-group input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--c-anthracite);
|
||||||
|
color: var(--c-text);
|
||||||
|
border: 1px solid rgba(244, 241, 247, 0.16);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
padding: 0.5em 0.7em;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.coupon-input-group input:focus { outline: none; border-color: var(--c-accent); }
|
||||||
|
.btn-sm { padding: 0.5em 0.9em; font-size: 0.85rem; white-space: nowrap; }
|
||||||
|
#coupon-status { margin-top: 0.4em; color: var(--c-accent); }
|
||||||
|
#coupon-status a { color: var(--c-text-muted); margin-left: 0.5em; }
|
||||||
|
#coupon-status a:hover { color: var(--c-sale); }
|
||||||
@media (max-width: 800px) { .cart-layout { grid-template-columns: 1fr; } }
|
@media (max-width: 800px) { .cart-layout { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
/* Checkout */
|
/* Checkout */
|
||||||
@@ -896,8 +963,24 @@ a:focus-visible, button:focus-visible {
|
|||||||
background: var(--c-anthracite);
|
background: var(--c-anthracite);
|
||||||
transition: border-color 0.2s var(--ease), background 0.2s var(--ease), box-shadow 0.2s var(--ease), transform 0.2s var(--ease);
|
transition: border-color 0.2s var(--ease), background 0.2s var(--ease), box-shadow 0.2s var(--ease), transform 0.2s var(--ease);
|
||||||
}
|
}
|
||||||
.pay-option:hover { border-color: color-mix(in srgb, var(--c-accent) 45%, transparent); transform: translateY(-1px); }
|
.pay-option:hover { border-color: color-mix(in srgb, var(--brand-color, var(--c-accent)) 45%, transparent); transform: translateY(-1px); }
|
||||||
.pay-option .pay-icon { font-size: 1.3rem; line-height: 1; flex-shrink: 0; }
|
|
||||||
|
/* Icon-Badge im echten Markenlogo-Stil (farbige Kachel + Buchstabe/Symbol) statt schlichtem
|
||||||
|
Emoji — wirkt "fertig", nicht wie ein Platzhalter. */
|
||||||
|
.pay-option .pay-icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 6px -2px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
.pay-option input[type="radio"] {
|
.pay-option input[type="radio"] {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 19px;
|
width: 19px;
|
||||||
@@ -918,15 +1001,16 @@ a:focus-visible, button:focus-visible {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transform: scale(0);
|
transform: scale(0);
|
||||||
transition: transform 0.15s var(--ease);
|
transition: transform 0.15s var(--ease);
|
||||||
background: var(--c-accent);
|
background: var(--brand-color, var(--c-accent));
|
||||||
}
|
}
|
||||||
.pay-option input[type="radio"]:checked::before { transform: scale(1); }
|
.pay-option input[type="radio"]:checked::before { transform: scale(1); }
|
||||||
.pay-option input[type="radio"]:checked { border-color: var(--c-accent); }
|
.pay-option input[type="radio"]:checked { border-color: var(--brand-color, var(--c-accent)); }
|
||||||
.pay-option:has(input:checked) {
|
.pay-option:has(input:checked) {
|
||||||
border-color: var(--c-accent);
|
border-color: var(--brand-color, var(--c-accent));
|
||||||
background: color-mix(in srgb, var(--c-accent) 9%, var(--c-anthracite));
|
background: color-mix(in srgb, var(--brand-color, var(--c-accent)) 12%, var(--c-anthracite));
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--c-accent) 45%, transparent), 0 4px 14px -6px color-mix(in srgb, var(--c-accent) 55%, transparent);
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--brand-color, var(--c-accent)) 55%, transparent), 0 4px 14px -6px color-mix(in srgb, var(--brand-color, var(--c-accent)) 65%, transparent);
|
||||||
}
|
}
|
||||||
|
.pay-option:has(input:checked) span:not(.pay-icon) { color: var(--brand-color, var(--c-accent)); }
|
||||||
|
|
||||||
.checkout-summary .row { display: flex; justify-content: space-between; font-size: 0.9rem; padding: 0.35rem 0; color: var(--c-text-muted); }
|
.checkout-summary .row { display: flex; justify-content: space-between; font-size: 0.9rem; padding: 0.35rem 0; color: var(--c-text-muted); }
|
||||||
.checkout-summary .row.total { color: var(--c-text); font-weight: 700; font-size: 1.1rem; padding-top: 0.6rem; margin-top: 0.3rem; border-top: 1px solid rgba(244, 241, 247, 0.1); }
|
.checkout-summary .row.total { color: var(--c-text); font-weight: 700; font-size: 1.1rem; padding-top: 0.6rem; margin-top: 0.3rem; border-top: 1px solid rgba(244, 241, 247, 0.1); }
|
||||||
|
|||||||
Reference in New Issue
Block a user