Zahlungsarten überarbeitet: echtes PayPal, Klarna & eigene Kartenmaske raus

Auf ausdrücklichen Wunsch, mit Fokus auf rechtliche Absicherung:

- Klarna komplett entfernt (hätte eigene Händlerprüfung + Bonitätsprüfungs-
  Pflichten nach der neuen EU-Verbraucherkreditrichtlinie vorausgesetzt).
- Eigene Kreditkarten-Eingabemaske komplett entfernt (wäre PCI-DSS-pflichtig
  gewesen — für einen kleinen Shop praktisch nicht stemmbar). Kartenzahlung
  bleibt möglich: PayPals eigener, PCI-zertifizierter Gast-Checkout bietet
  Kredit-/Debitkarte an, ohne dass Kartendaten je unsere Seite berühren.
- PayPal ist jetzt ECHT server-seitig verifiziert statt dem Browser blind zu
  vertrauen: eigener Cloudflare-Function-Flow (functions/_shared/paypal.js +
  functions/api/paypal/) legt die PayPal-Bestellung server-seitig an, zieht
  die Zahlung nach Bestätigung server-seitig ein und prüft den eingezogenen
  Betrag gegen die Bestellsumme — die Bestellung wird ausschließlich bei
  bestätigter, betragsgleicher Zahlung angelegt. /api/orders lehnt direkte
  PayPal-Bestellungen jetzt ausdrücklich ab (verhindert vorgetäuschte
  "bezahlte" Bestellungen ohne echte Zahlung).
- Neuer, ehrlicherer Bestellstatus "zahlungOffen": Überweisungs-Bestellungen
  starten jetzt so (Geld noch nicht da) statt fälschlich sofort "bezahlt" zu
  heißen — Schutz vor Warenversand, bevor das Geld wirklich angekommen ist.
  Eigene Kachel/Filter/Badge-Farbe in der Verwaltung, Umsatz-/Auswertungs-
  Zahlen zählen "zahlungOffen" bewusst nicht mit.
- Rechtstexte (AGB, Datenschutzerklärung, FAQ, Versand & Zahlung) auf allen
  4 Sprachen aktualisiert: nur noch PayPal + Überweisung erwähnt, inkl. DSGVO-
  Hinweis zur internationalen Datenübertragung an PayPal (Data Privacy
  Framework-Zertifizierung).
- D1-Migration 0003: neue Spalte paypal_order_id (Zahlungsbeleg) + erweiterter
  Status-Wertebereich, auf Live-Datenbank angewendet, bestehende Daten intakt.

Mit echten Testbestellungen lokal verifiziert: Überweisung legt korrekt
"zahlungOffen" an, direkter PayPal-Bypass-Versuch an /api/orders wird
abgelehnt, PayPal-Route meldet sauber "noch nicht eingerichtet" ohne
Zugangsdaten. Checkout-UI zeigt nur noch 2 Zahlungsarten, Bestellknopf ist bei
PayPal ausgeblendet (Zahlung läuft exklusiv über den echten PayPal-Button).

Für den echten Zahlungseingang fehlt noch VanVans eigenes PayPal-Business-
Konto (Client-ID + Secret) — Details in der Vault-Dokumentation.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
qcigano
2026-08-04 01:06:41 +02:00
co-authored by Claude Sonnet 5
parent 98028df4ea
commit 81c175d03c
18 changed files with 1077 additions and 743 deletions
+144 -131
View File
@@ -20,65 +20,26 @@ const t = useTranslations(lang);
<div class="checkout-split">
<div class="checkout-split-col">
<h3>{t.checkout.step2}</h3>
{/* Echte Markenlogos statt Platzhaltern: PayPal- und Klarna-Icon in den offiziellen
Markenfarben, Visa/Mastercard als unveränderte "Acceptance Marks" nebeneinander
genau die in den jeweiligen Markenrichtlinien vorgesehene Verwendung, um eine
Zahlungsmethode im Checkout auszuweisen (siehe Recherche zu PayPal-/Klarna-/Visa-/
Mastercard-Markenrichtlinien: Logos dürfen zur Kennzeichnung akzeptierter
Zahlungsarten verwendet werden, Visa/Mastercard aber nur unverändert/unverfälscht). */}
{/* Nur noch zwei Zahlungsarten (auf ausdrücklichen Wunsch, siehe Recherche zu Rechts-/
Kostenlage): Klarna raus (würde eine eigene Händlerprüfung + Bonitätsprüfungs-
Pflichten voraussetzen, siehe EU-Verbraucherkreditrichtlinie) und keine eigene
Kreditkarten-Eingabemaske mehr (wäre PCI-DSS-pflichtig — für einen kleinen Shop
praktisch nicht stemmbar). Kartenzahlung ist trotzdem möglich: PayPal bietet als
Gast-Checkout auch Kredit-/Debitkarte an, komplett auf PayPals eigener, PCI-
zertifizierter Seite — die Kartendaten berühren unsere Seite nie. */}
<div class="payment-options">
<div class="pay-card" style="--brand-color:#009cde; --brand-bg:#003087;">
<label class="pay-option">
<span class="pay-icon pay-icon-brand" style="background:linear-gradient(160deg, #0091e6, #003087);">
<svg viewBox="0 0 24 24" width="30" height="30" fill="#fff" aria-hidden="true"><path d="M15.607 4.653H8.941L6.645 19.251H1.82L4.862 0h7.995c3.754 0 6.375 2.294 6.473 5.513-.648-.478-2.105-.86-3.722-.86m6.57 5.546c0 3.41-3.01 6.853-6.958 6.853h-2.493L11.595 24H6.74l1.845-11.538h3.592c4.208 0 7.346-3.634 7.153-6.949a5.24 5.24 0 0 1 2.848 4.686M9.653 5.546h6.408c.907 0 1.942.222 2.363.541-.195 2.741-2.655 5.483-6.441 5.483H8.714Z"></path></svg>
</span>
<span>PayPal</span>
<span>PayPal<span class="pay-sub">{t.checkout.payPalSub}<span class="pay-sub-cards" aria-hidden="true"><span class="mini-card mini-card-visa"><svg viewBox="0 0 24 24" fill="#1434CB" aria-hidden="true"><path d="M9.112 8.262L5.97 15.758H3.92L2.374 9.775c-.094-.368-.175-.503-.461-.658C1.447 8.864.677 8.627 0 8.479l.046-.217h3.3a.904.904 0 01.894.764l.817 4.338 2.018-5.102zm8.033 5.049c.008-1.979-2.736-2.088-2.717-2.972.006-.269.262-.555.822-.628a3.66 3.66 0 011.913.336l.34-1.59a5.207 5.207 0 00-1.814-.333c-1.917 0-3.266 1.02-3.278 2.479-.012 1.079.963 1.68 1.698 2.04.756.367 1.01.603 1.006.931-.005.504-.602.725-1.16.734-.975.015-1.54-.263-1.992-.473l-.351 1.642c.453.208 1.289.39 2.156.398 2.037 0 3.37-1.006 3.377-2.564m5.061 2.447H24l-1.565-7.496h-1.656a.883.883 0 00-.826.55l-2.909 6.946h2.036l.405-1.12h2.488zm-2.163-2.656l1.02-2.815.588 2.815zm-8.16-4.84l-1.603 7.496H8.34l1.605-7.496z"></path></svg></span><span class="mini-card mini-card-mastercard"><svg viewBox="0 0 32 20" aria-hidden="true"><circle cx="13" cy="10" r="7.2" fill="#EB001B"></circle><circle cx="19" cy="10" r="7.2" fill="#F79E1B"></circle><path d="M16 4.4a7.18 7.18 0 010 11.2 7.18 7.18 0 010-11.2z" fill="#FF5F00"></path></svg></span></span></span></span>
<input type="radio" name="pay" value="paypal" checked />
</label>
<div class="pay-panel-inline">
<p>{t.checkout.payPalHint}</p>
<div id="paypal-button-container"></div>
</div>
</div>
<div class="pay-card" style="--brand-color:#ffb3c7; --brand-bg:#17120f;">
<label class="pay-option">
<span class="pay-icon pay-icon-brand" style="background:linear-gradient(160deg, #ffd1de, #ffb3c7);">
<svg viewBox="0 0 24 24" width="26" height="26" fill="#0a0a0a" aria-hidden="true"><path d="M4.592 2v20H0V2h4.592zm11.46 0c0 4.194-1.583 8.105-4.415 11.068l-.278.283L17.702 22h-5.668l-6.893-9.4 1.779-1.332c2.858-2.14 4.535-5.378 4.637-8.924L11.562 2h4.49zM21.5 17a2.5 2.5 0 110 5 2.5 2.5 0 010-5z"></path></svg>
</span>
<span>Klarna</span>
<input type="radio" name="pay" value="klarna" />
</label>
<div class="pay-panel-inline">
<p>{t.checkout.klarnaHint}</p>
<button type="button" class="btn btn-outline pay-connect-btn" id="klarna-connect">{t.checkout.klarnaConnect}</button>
<p class="pay-connect-status" id="klarna-status"></p>
</div>
</div>
<div class="pay-card" style="--brand-color:#e3b23c; --brand-bg:#241f3d;">
<label class="pay-option">
<span class="pay-icon-cards" aria-hidden="true">
<span class="mini-card mini-card-visa">
<svg viewBox="0 0 24 24" width="28" height="28" fill="#1434CB" aria-hidden="true"><path d="M9.112 8.262L5.97 15.758H3.92L2.374 9.775c-.094-.368-.175-.503-.461-.658C1.447 8.864.677 8.627 0 8.479l.046-.217h3.3a.904.904 0 01.894.764l.817 4.338 2.018-5.102zm8.033 5.049c.008-1.979-2.736-2.088-2.717-2.972.006-.269.262-.555.822-.628a3.66 3.66 0 011.913.336l.34-1.59a5.207 5.207 0 00-1.814-.333c-1.917 0-3.266 1.02-3.278 2.479-.012 1.079.963 1.68 1.698 2.04.756.367 1.01.603 1.006.931-.005.504-.602.725-1.16.734-.975.015-1.54-.263-1.992-.473l-.351 1.642c.453.208 1.289.39 2.156.398 2.037 0 3.37-1.006 3.377-2.564m5.061 2.447H24l-1.565-7.496h-1.656a.883.883 0 00-.826.55l-2.909 6.946h2.036l.405-1.12h2.488zm-2.163-2.656l1.02-2.815.588 2.815zm-8.16-4.84l-1.603 7.496H8.34l1.605-7.496z"></path></svg>
</span>
<span class="mini-card mini-card-mastercard">
<svg viewBox="0 0 32 20" width="32" height="20" aria-hidden="true"><circle cx="13" cy="10" r="7.2" fill="#EB001B"></circle><circle cx="19" cy="10" r="7.2" fill="#F79E1B"></circle><path d="M16 4.4a7.18 7.18 0 010 11.2 7.18 7.18 0 010-11.2z" fill="#FF5F00"></path></svg>
</span>
</span>
<span>{t.checkout.creditCard}<span class="pay-sub">{t.checkout.creditCardSub}</span></span>
<input type="radio" name="pay" value="kreditkarte" />
</label>
<div class="pay-panel-inline">
<p>{t.checkout.cardHint}</p>
<div class="card-fields">
<div>
<label for="cc-number">{t.checkout.cardNumber}</label>
<input type="text" id="cc-number" inputmode="numeric" placeholder="1234 5678 9012 3456" maxlength="19" />
</div>
<div class="grid grid-2">
<div><label for="cc-expiry">{t.checkout.cardExpiry}</label><input type="text" id="cc-expiry" placeholder="MM/YY" maxlength="5" /></div>
<div><label for="cc-cvc">{t.checkout.cardCvc}</label><input type="text" id="cc-cvc" inputmode="numeric" placeholder="123" maxlength="4" /></div>
</div>
</div>
<p class="pay-connect-status" id="paypal-error" style="display:none; color: var(--c-sale);"></p>
</div>
</div>
<div class="pay-card" style="--brand-color:#7fa8c9; --brand-bg:#3b5a76;">
@@ -137,7 +98,11 @@ const t = useTranslations(lang);
</div>
</div>
<button class="btn btn-primary btn-block" type="submit">{t.checkout.orderButton}</button>
{/* Dieser Knopf gehört NUR zur Überweisung — bei ausgewähltem PayPal wird er per CSS
(siehe global.css, #checkout-form:has(...)) ausgeblendet und stattdessen der Hinweis
darunter gezeigt, dass die Zahlung über den echten PayPal-Button oben läuft. */}
<p id="order-submit-hint">{t.checkout.payPalCompleteHint}</p>
<button class="btn btn-primary btn-block" type="submit" id="order-submit-btn">{t.checkout.orderButton}</button>
</form>
<aside class="card checkout-aside">
@@ -173,12 +138,12 @@ const t = useTranslations(lang);
</section>
</Layout>
<script define:vars={{ freeLabel: t.checkout.free, totalLabel: t.checkout.total, shippingLabel: t.cart.shipping, emptyCartAlert: t.checkout.emptyCartAlert, thankYouPath: "/checkout/danke/", checkoutLang: lang, discountLabel: t.cart.discount, couponInvalid: t.cart.couponInvalid, couponAppliedTemplate: t.cart.couponApplied("__C__"), couponRemoveLabel: t.cart.couponRemove, klarnaConnectLabel: t.checkout.klarnaConnect, klarnaConnectingLabel: t.checkout.klarnaConnecting, klarnaConnectedLabel: t.checkout.klarnaConnected, bankHolderLabel: t.checkout.bankHolder, bankIbanLabel: t.checkout.bankIban, bankBicLabel: t.checkout.bankBic, bankBankNameLabel: t.checkout.bankBankName, bankPlaceholderLabel: t.checkout.bankPlaceholder, partnerDiscountLabel: t.cart.partnerDiscount, partnerCodeInvalid: t.cart.partnerCodeInvalid, partnerCodeAppliedTemplate: t.cart.partnerCodeApplied("__C__"), partnerCodeRemoveLabel: t.cart.partnerCodeRemove, loyaltyDiscountLabel: t.cart.loyaltyDiscount, aboDiscountLabel: t.cart.aboDiscount, gratisPraemieLabel: t.cart.gratisPraemie }}>
<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, bankHolderLabel: t.checkout.bankHolder, bankIbanLabel: t.checkout.bankIban, bankBicLabel: t.checkout.bankBic, bankBankNameLabel: t.checkout.bankBankName, bankPlaceholderLabel: t.checkout.bankPlaceholder, partnerDiscountLabel: t.cart.partnerDiscount, partnerCodeInvalid: t.cart.partnerCodeInvalid, partnerCodeAppliedTemplate: t.cart.partnerCodeApplied("__C__"), partnerCodeRemoveLabel: t.cart.partnerCodeRemove, loyaltyDiscountLabel: t.cart.loyaltyDiscount, aboDiscountLabel: t.cart.aboDiscount, gratisPraemieLabel: t.cart.gratisPraemie, payPalCompleteHintLabel: t.checkout.payPalCompleteHint }}>
// WICHTIG: define:vars-Skripte werden von Astro in ein IIFE gepackt (kein ES-Modul) — echte
// `import`-Anweisungen würden hier zur Laufzeit mit "Cannot use import statement outside a
// module" fehlschlagen. Deshalb hier nur die vom Server gerenderten Werte auf window ablegen,
// die eigentliche Logik läuft im separaten <script type="module"> darunter.
window.__checkoutVars = { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang, discountLabel, couponInvalid, couponAppliedTemplate, couponRemoveLabel, klarnaConnectLabel, klarnaConnectingLabel, klarnaConnectedLabel, bankHolderLabel, bankIbanLabel, bankBicLabel, bankBankNameLabel, bankPlaceholderLabel, partnerDiscountLabel, partnerCodeInvalid, partnerCodeAppliedTemplate, partnerCodeRemoveLabel, loyaltyDiscountLabel, aboDiscountLabel, gratisPraemieLabel };
window.__checkoutVars = { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang, discountLabel, couponInvalid, couponAppliedTemplate, couponRemoveLabel, bankHolderLabel, bankIbanLabel, bankBicLabel, bankBankNameLabel, bankPlaceholderLabel, partnerDiscountLabel, partnerCodeInvalid, partnerCodeAppliedTemplate, partnerCodeRemoveLabel, loyaltyDiscountLabel, aboDiscountLabel, gratisPraemieLabel, payPalCompleteHintLabel };
</script>
<script type="module">
import { getCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon, appliedPartnerCode, partnerCodeDiscount, setPartnerCode, clearPartnerCode, totalDiscount, treuebonusDiscount, aboDiscount, istAusgewaehlt, zeilenpreisFuer } from "../../scripts/cart";
@@ -188,7 +153,15 @@ const t = useTranslations(lang);
import { bankverbindung, bankverbindungVollstaendig } from "../../data/bankverbindung";
import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../scripts/account";
const { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang, discountLabel, couponInvalid, couponAppliedTemplate, couponRemoveLabel, klarnaConnectLabel, klarnaConnectingLabel, klarnaConnectedLabel, bankHolderLabel, bankIbanLabel, bankBicLabel, bankBankNameLabel, bankPlaceholderLabel, partnerDiscountLabel, partnerCodeInvalid, partnerCodeAppliedTemplate, partnerCodeRemoveLabel, loyaltyDiscountLabel, aboDiscountLabel, gratisPraemieLabel } = window.__checkoutVars;
const { freeLabel, totalLabel, shippingLabel, emptyCartAlert, thankYouPath, checkoutLang, discountLabel, couponInvalid, couponAppliedTemplate, couponRemoveLabel, bankHolderLabel, bankIbanLabel, bankBicLabel, bankBankNameLabel, bankPlaceholderLabel, partnerDiscountLabel, partnerCodeInvalid, partnerCodeAppliedTemplate, partnerCodeRemoveLabel, loyaltyDiscountLabel, aboDiscountLabel, gratisPraemieLabel, payPalCompleteHintLabel } = window.__checkoutVars;
// TODO (VanVan/qciga): "sb" ist PayPals kostenlose Sandbox-Test-ID — funktioniert sofort, ohne
// eigenes Konto, aber es fließt dabei nie echtes Geld. Für echten Zahlungseingang hier die
// eigene PayPal-Business-Client-ID eintragen (kostenlos unter developer.paypal.com, siehe
// Vault-Doku "Zugänge & Zugangscodes"). Die Client-ID ist NICHT geheim (anders als das
// zugehörige Secret, das ausschließlich server-seitig als Cloudflare-Secret hinterlegt wird,
// siehe functions/_shared/paypal.js) — sie darf hier im Quellcode stehen.
const PAYPAL_CLIENT_ID = "sb";
const summary = document.getElementById("checkout-summary");
const landSelect = document.getElementById("land");
@@ -255,59 +228,126 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
renderSummary();
landSelect.addEventListener("change", renderSummary);
// Zahlungsart-Auswahl: der "Verbinden"-Bereich jeder Karte klappt rein über CSS
// Zahlungsart-Auswahl: der Panel-Bereich jeder Karte klappt rein über CSS
// (:has(input:checked)) sofort auf, sobald man die Karte anklickt — kein JS, keine Verzögerung.
// Ebenso blendet CSS den großen "Jetzt bestellen"-Knopf aus, sobald PayPal gewählt ist (siehe
// global.css) — die Zahlung läuft dann ausschließlich über den PayPal-Button unten.
// PayPal: echte PayPal Smart Buttons über das offizielle JS-SDK (Sandbox-Client-ID "sb" —
// kostenlos, ohne eigenes Konto testbar). Klick öffnet den echten PayPal-Login-Popup. Für den
// Live-Betrieb muss hier nur "sb" durch VanVans echte (kostenlose) PayPal-Business-Client-ID
// ersetzt werden, siehe developer.paypal.com.
// Baut das Bestell-Objekt, das sowohl an /api/orders (Überweisung) als auch an
// /api/paypal/capture-order (PayPal, nach bestätigter Zahlung) geschickt wird — identisches
// Format, damit beide Wege dieselbe Server-Logik (functions/_shared/bestellung-erstellen.js)
// nutzen können.
function baueBestellPayload() {
return {
land: landSelect.value,
kunde: {
name: `${document.getElementById("vorname").value.trim()} ${document.getElementById("nachname").value.trim()}`.trim(),
email: document.getElementById("email").value.trim(),
strasse: document.getElementById("strasse").value.trim(),
plz: document.getElementById("plz").value.trim(),
ort: document.getElementById("ort").value.trim(),
},
artikel: cart.map((i) => {
const produkt = getProduct(i.slug);
const einzelpreis = i.gratis ? 0 : zeilenpreisFuer(i) / i.menge;
return { slug: i.slug, name: i.name, kategorie: produkt?.kategorie ?? "", menge: i.menge, preis: einzelpreis, gratis: !!i.gratis };
}),
zwischensumme: subtotal,
versandkosten: currentShipping,
rabattGesamt: currentRabattGesamt,
summe: currentTotal,
treuebonusVerwendet: treuebonusDiscount() > 0,
aboRabattVerwendet: aboDiscount(subtotal) > 0,
gutscheinCode: appliedCoupon()?.code ?? null,
};
}
// Läuft nach JEDER erfolgreich gespeicherten Bestellung, egal ob per Überweisung oder PayPal.
function nachBestellErfolg() {
// Treuebonus (siehe scripts/account.ts): Eine Bestellung zählt entweder für den NÄCHSTEN
// Durchlauf (registriereAbgeschlosseneBestellung) ODER löst eine bereits wartende Belohnung
// ein (treuebonusEinloesen) — nie beides gleichzeitig.
if (treuebonusDiscount() > 0) {
treuebonusEinloesen();
} else {
registriereAbgeschlosseneBestellung();
}
location.href = thankYouPath;
}
// PayPal: ECHTE, server-seitig verifizierte Zahlungsabwicklung (siehe functions/api/paypal/ +
// functions/_shared/paypal.js). Der Browser vertraut NICHT mehr blind seinem eigenen
// "capture()"-Aufruf — createOrder lässt UNSEREN Server die PayPal-Bestellung anlegen (mit dem
// von uns berechneten Betrag), onApprove lässt UNSEREN Server die Zahlung bei PayPal
// einziehen/prüfen und ERST DANACH die Bestellung speichern.
let paypalLoaded = false;
const paypalErrorEl = document.getElementById("paypal-error");
function zeigePaypalFehler(msg) {
if (!paypalErrorEl) return;
paypalErrorEl.textContent = "⚠️ " + msg;
paypalErrorEl.style.display = "block";
}
function loadPayPalButtons() {
if (paypalLoaded || !document.getElementById("paypal-button-container")) return;
paypalLoaded = true;
const script = document.createElement("script");
script.src = "https://www.paypal.com/sdk/js?client-id=sb&currency=EUR&intent=capture&disable-funding=card,credit";
// disable-funding NICHT gesetzt: PayPals Gast-Checkout mit Kredit-/Debitkarte bleibt bewusst
// verfügbar — das ist die sichere Alternative zu einer eigenen Kartenmaske (siehe oben).
script.src = `https://www.paypal.com/sdk/js?client-id=${PAYPAL_CLIENT_ID}&currency=EUR&intent=capture`;
script.onload = () => {
if (!window.paypal) return;
window.paypal.Buttons({
style: { layout: "vertical", color: "blue", shape: "pill", label: "paypal" },
createOrder: (data, actions) => actions.order.create({
purchase_units: [{ amount: { value: Math.max(0.01, currentTotal).toFixed(2), currency_code: "EUR" } }],
}),
onApprove: (data, actions) => actions.order.capture().then(() => { location.href = thankYouPath; }),
createOrder: async () => {
if (cart.length === 0) {
alert(emptyCartAlert);
throw new Error("Warenkorb leer");
}
if (paypalErrorEl) paypalErrorEl.style.display = "none";
const res = await fetch("/api/paypal/create-order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ summe: currentTotal }),
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) {
zeigePaypalFehler(data?.error || "PayPal-Bestellung konnte nicht angelegt werden.");
throw new Error(data?.error || "PayPal-Bestellung fehlgeschlagen.");
}
return data.id;
},
onApprove: async (data) => {
try {
const res = await fetch("/api/paypal/capture-order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paypalOrderId: data.orderID, ...baueBestellPayload() }),
});
const result = await res.json().catch(() => null);
if (!res.ok || !result?.ok) {
zeigePaypalFehler(result?.error || "Zahlung konnte nicht abgeschlossen werden.");
return;
}
nachBestellErfolg();
} catch (err) {
zeigePaypalFehler(err instanceof Error ? err.message : "Unbekannter Fehler bei der Zahlung.");
}
},
onError: () => {
zeigePaypalFehler("PayPal hat einen Fehler gemeldet. Bitte versuche es erneut oder wähle Überweisung.");
},
onCancel: () => {
if (paypalErrorEl) paypalErrorEl.style.display = "none";
},
}).render("#paypal-button-container");
};
document.head.appendChild(script);
}
loadPayPalButtons();
// Klarna: echtes "Verbinden" braucht eine serverseitige Session mit VanVans Klarna-Händlerkonto
// (Phase 2, Cloudflare Worker). Bis dahin ein sauber gestalteter Demo-Verbindungsablauf, damit
// die Oberfläche schon fertig aussieht und 1:1 austauschbar ist, sobald die Anbindung steht.
const klarnaBtn = document.getElementById("klarna-connect");
const klarnaStatus = document.getElementById("klarna-status");
function connectKlarna() {
if (!klarnaBtn || klarnaBtn.disabled) return;
klarnaBtn.disabled = true;
klarnaBtn.textContent = klarnaConnectingLabel;
setTimeout(() => {
klarnaBtn.textContent = klarnaConnectLabel;
klarnaBtn.disabled = false;
klarnaStatus.textContent = "✅ " + klarnaConnectedLabel;
}, 1100);
}
if (klarnaBtn) {
klarnaBtn.addEventListener("click", connectKlarna);
const klarnaRadio = document.querySelector('input[name="pay"][value="klarna"]');
// Verbindet automatisch, sobald die Klarna-Karte ausgewählt wird — kein separater Klick
// auf den Button nötig (der bleibt als "erneut verbinden" erhalten).
klarnaRadio?.addEventListener("change", () => { if (!klarnaStatus.textContent) connectKlarna(); });
}
// Banküberweisung (Vorkasse): braucht KEINE externe Anbindung — zeigt einfach VanVans echte,
// im Adminbereich hinterlegte Kontodaten an. Das ist die einzige der vier Zahlungsarten, die
// schon zu 100% "fertig" funktioniert.
// im Adminbereich hinterlegte Kontodaten an. Die Bestellung wird erst nach Zahlungseingang
// manuell in /verwaltung/ von "Zahlung ausstehend" auf "Bezahlt" gestellt.
const bankDetailsEl = document.getElementById("bank-details");
if (bankDetailsEl) {
if (bankverbindungVollstaendig(bankverbindung)) {
@@ -341,13 +381,16 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
});
const orderForm = document.getElementById("checkout-form");
const submitBtn = orderForm.querySelector('button[type="submit"]');
const submitBtn = document.getElementById("order-submit-btn");
const orderErrorEl = document.createElement("p");
orderErrorEl.className = "small";
orderErrorEl.style.color = "var(--c-sale)";
orderErrorEl.style.display = "none";
submitBtn.insertAdjacentElement("beforebegin", orderErrorEl);
// Dieser Handler ist jetzt AUSSCHLIESSLICH für Überweisung zuständig — der Knopf ist bei
// ausgewähltem PayPal per CSS unsichtbar, hier trotzdem ein zusätzliches Sicherheitsnetz
// (z.B. Absenden per Enter-Taste in einem Textfeld), falls PayPal doch ausgewählt sein sollte.
orderForm.addEventListener("submit", async (e) => {
e.preventDefault();
if (cart.length === 0) {
@@ -355,63 +398,33 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
return;
}
// Echte Bestellung in der Datenbank anlegen (siehe functions/api/orders.js) — Phase 1 hat
// kein echtes Zahlungs-Backend, jede vollständig abgeschickte Bestellung gilt sofort als
// "bezahlt" (Server setzt diesen Status automatisch). Erst NACH erfolgreicher Antwort geht
// es weiter — bei einem Fehler bleibt man im Checkout und sieht eine klare Meldung, statt
// trotzdem auf die Danke-Seite zu landen, obwohl die Bestellung nirgends gespeichert wurde.
const zahlungsart = document.querySelector('input[name="pay"]:checked')?.value || "";
if (zahlungsart === "paypal") {
orderErrorEl.textContent = "⚠️ " + payPalCompleteHintLabel;
orderErrorEl.style.display = "block";
return;
}
// Echte Bestellung in der Datenbank anlegen (siehe functions/api/orders.js) — Status
// "zahlungOffen", da bei Überweisung das Geld noch nicht da ist. Erst NACH erfolgreicher
// Antwort geht es weiter — bei einem Fehler bleibt man im Checkout und sieht eine klare
// Meldung, statt trotzdem auf die Danke-Seite zu landen, obwohl nichts gespeichert wurde.
orderErrorEl.style.display = "none";
submitBtn.disabled = true;
const urspruenglicherBtnText = submitBtn.textContent;
submitBtn.textContent = "…";
const zahlungsart = document.querySelector('input[name="pay"]:checked')?.value || "";
const payload = {
land: landSelect.value,
kunde: {
name: `${document.getElementById("vorname").value.trim()} ${document.getElementById("nachname").value.trim()}`.trim(),
email: document.getElementById("email").value.trim(),
strasse: document.getElementById("strasse").value.trim(),
plz: document.getElementById("plz").value.trim(),
ort: document.getElementById("ort").value.trim(),
},
zahlungsart,
artikel: cart.map((i) => {
const produkt = getProduct(i.slug);
const einzelpreis = i.gratis ? 0 : zeilenpreisFuer(i) / i.menge;
return { slug: i.slug, name: i.name, kategorie: produkt?.kategorie ?? "", menge: i.menge, preis: einzelpreis, gratis: !!i.gratis };
}),
zwischensumme: subtotal,
versandkosten: currentShipping,
rabattGesamt: currentRabattGesamt,
summe: currentTotal,
treuebonusVerwendet: treuebonusDiscount() > 0,
aboRabattVerwendet: aboDiscount(subtotal) > 0,
gutscheinCode: appliedCoupon()?.code ?? null,
};
try {
const res = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
body: JSON.stringify({ ...baueBestellPayload(), zahlungsart }),
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) {
throw new Error(data?.error || "Bestellung konnte nicht gespeichert werden.");
}
// Treuebonus (siehe scripts/account.ts): Eine Bestellung zählt entweder für den NÄCHSTEN
// Durchlauf (registriereAbgeschlosseneBestellung) ODER löst eine bereits wartende Belohnung
// ein (treuebonusEinloesen) — nie beides gleichzeitig. Läuft ERST NACH erfolgreichem
// Speichern der echten Bestellung, damit lokaler Kontostand und echte Bestelldaten nie
// auseinanderlaufen.
if (treuebonusDiscount() > 0) {
treuebonusEinloesen();
} else {
registriereAbgeschlosseneBestellung();
}
location.href = thankYouPath;
nachBestellErfolg();
} catch (err) {
orderErrorEl.textContent = "⚠️ " + (err instanceof Error ? err.message : "Bestellung konnte nicht gespeichert werden. Bitte nochmal versuchen.");
orderErrorEl.style.display = "block";