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:
co-authored by
Claude Sonnet 5
parent
98028df4ea
commit
81c175d03c
@@ -0,0 +1,63 @@
|
||||
-- Migration 0003: echte PayPal-Zahlungsabwicklung + realistischer Status "zahlungOffen".
|
||||
--
|
||||
-- Zwei Dinge ändern sich:
|
||||
-- 1. Neue Spalte "paypal_order_id" — speichert die echte, von PayPal bestätigte Bestell-ID als
|
||||
-- Zahlungsbeleg (siehe functions/api/paypal/capture-order.js).
|
||||
-- 2. Der Status-Wertebereich bekommt "zahlungOffen" dazu (für Überweisung/Vorkasse, bevor das
|
||||
-- Geld wirklich da ist) — vorher wurde JEDE Bestellung sofort als "bezahlt" markiert, auch
|
||||
-- wenn noch gar nichts überwiesen war. Das war in der Phase-1-Testphase okay, ist aber ein
|
||||
-- echtes Geschäftsrisiko (Ware könnte vor Zahlungseingang verschickt werden).
|
||||
--
|
||||
-- SQLite kann CHECK-Constraints nicht per ALTER TABLE ändern — deshalb wird die Tabelle nach dem
|
||||
-- Standard-SQLite-Vorgehen neu aufgebaut: neue Tabelle mit korrektem Constraint anlegen, alle
|
||||
-- bestehenden Zeilen 1:1 rüberkopieren (bestehende "bezahlt"-Bestellungen bleiben "bezahlt" —
|
||||
-- die waren ja im alten Phase-1-System tatsächlich abgeschlossen), alte Tabelle löschen, neue
|
||||
-- umbenennen.
|
||||
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
CREATE TABLE orders_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bestellnummer TEXT UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'zahlungOffen'
|
||||
CHECK (status IN ('zahlungOffen', 'bezahlt', 'bearbeitung', 'versandVorbereitet', 'versendet', 'abgeschlossen', 'storniert')),
|
||||
kunde_name TEXT NOT NULL,
|
||||
kunde_email TEXT NOT NULL,
|
||||
land TEXT NOT NULL CHECK (land IN ('de', 'at', 'ch', 'lu')),
|
||||
strasse TEXT,
|
||||
plz TEXT,
|
||||
ort TEXT,
|
||||
zahlungsart TEXT,
|
||||
tracking TEXT,
|
||||
gutschein_code TEXT,
|
||||
paypal_order_id TEXT,
|
||||
zwischensumme REAL NOT NULL DEFAULT 0,
|
||||
versandkosten REAL NOT NULL DEFAULT 0,
|
||||
rabatt_gesamt REAL NOT NULL DEFAULT 0,
|
||||
summe REAL NOT NULL DEFAULT 0,
|
||||
treuebonus_verwendet INTEGER NOT NULL DEFAULT 0,
|
||||
abo_rabatt_verwendet INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
INSERT INTO orders_new (
|
||||
id, bestellnummer, created_at, updated_at, status, kunde_name, kunde_email, land, strasse, plz,
|
||||
ort, zahlungsart, tracking, gutschein_code, paypal_order_id, zwischensumme, versandkosten,
|
||||
rabatt_gesamt, summe, treuebonus_verwendet, abo_rabatt_verwendet
|
||||
)
|
||||
SELECT
|
||||
id, bestellnummer, created_at, updated_at, status, kunde_name, kunde_email, land, strasse, plz,
|
||||
ort, zahlungsart, tracking, gutschein_code, NULL, zwischensumme, versandkosten,
|
||||
rabatt_gesamt, summe, treuebonus_verwendet, abo_rabatt_verwendet
|
||||
FROM orders;
|
||||
|
||||
DROP TABLE orders;
|
||||
ALTER TABLE orders_new RENAME TO orders;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_land ON orders(land);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_gutschein_email ON orders(gutschein_code, kunde_email);
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
+13
-5
@@ -14,11 +14,15 @@ CREATE TABLE IF NOT EXISTS orders (
|
||||
bestellnummer TEXT UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
-- bezahlt: Phase-1-Zahlung gilt sofort als erfolgreich (siehe scripts/account.ts-Kommentar zum
|
||||
-- selben Thema) · bearbeitung/versandVorbereitet/versendet/abgeschlossen: manuelle Pflege durch
|
||||
-- qciga/VanVan in /verwaltung/ · storniert: für echte Rückabwicklungen/Fehlbestellungen.
|
||||
status TEXT NOT NULL DEFAULT 'bezahlt'
|
||||
CHECK (status IN ('bezahlt', 'bearbeitung', 'versandVorbereitet', 'versendet', 'abgeschlossen', 'storniert')),
|
||||
-- zahlungOffen: Bestellung eingegangen, Zahlung (Überweisung/Vorkasse) steht noch aus — Ware
|
||||
-- darf NICHT verschickt werden, bis qciga/VanVan den Geldeingang sieht und manuell auf
|
||||
-- "bezahlt" stellt (Schutz vor Warenverlust bei nie eingegangener Zahlung) · bezahlt: Zahlung
|
||||
-- bestätigt — bei PayPal ausschließlich, nachdem functions/api/paypal/capture-order.js die
|
||||
-- Zahlung server-seitig bei PayPal verifiziert hat (nie durch bloßes Absenden des Formulars) ·
|
||||
-- bearbeitung/versandVorbereitet/versendet/abgeschlossen: manuelle Pflege durch qciga/VanVan in
|
||||
-- /verwaltung/ · storniert: für echte Rückabwicklungen/Fehlbestellungen.
|
||||
status TEXT NOT NULL DEFAULT 'zahlungOffen'
|
||||
CHECK (status IN ('zahlungOffen', 'bezahlt', 'bearbeitung', 'versandVorbereitet', 'versendet', 'abgeschlossen', 'storniert')),
|
||||
kunde_name TEXT NOT NULL,
|
||||
kunde_email TEXT NOT NULL,
|
||||
land TEXT NOT NULL CHECK (land IN ('de', 'at', 'ch', 'lu')),
|
||||
@@ -32,6 +36,10 @@ CREATE TABLE IF NOT EXISTS orders (
|
||||
-- functions/api/orders.js): vor dem Anlegen einer neuen Bestellung wird geprüft, ob dieselbe
|
||||
-- E-Mail-Adresse denselben Code schon einmal (in einer nicht-stornierten Bestellung) benutzt hat.
|
||||
gutschein_code TEXT,
|
||||
-- Echte PayPal-Bestell-ID (aus der server-seitig verifizierten Zahlung, siehe
|
||||
-- functions/api/paypal/capture-order.js) — NULL bei allen anderen Zahlungsarten. Dient als
|
||||
-- Nachweis/Beleg, falls es je Rückfragen zu einer bestimmten PayPal-Zahlung gibt.
|
||||
paypal_order_id TEXT,
|
||||
zwischensumme REAL NOT NULL DEFAULT 0,
|
||||
versandkosten REAL NOT NULL DEFAULT 0,
|
||||
rabatt_gesamt REAL NOT NULL DEFAULT 0,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/* =====================================================================
|
||||
functions/_shared/bestellung-erstellen.js — die eigentliche "Bestellung anlegen"-Logik,
|
||||
herausgelöst aus functions/api/orders.js, damit sie von ZWEI Stellen sicher wiederverwendet
|
||||
werden kann:
|
||||
1. functions/api/orders.js — für Überweisung (Vorkasse), Status "zahlungOffen".
|
||||
2. functions/api/paypal/capture-order.js — für PayPal, aber NUR nachdem die Zahlung
|
||||
server-seitig bei PayPal selbst als abgeschlossen bestätigt wurde (siehe
|
||||
functions/_shared/paypal.js) — Status dann "bezahlt".
|
||||
|
||||
Gibt IMMER ein Ergebnisobjekt zurück ({ ok, ... } oder { ok:false, httpStatus, error }), NIE
|
||||
direkt eine Response — die aufrufende Route entscheidet selbst, wie sie antwortet. ===================================================================== */
|
||||
|
||||
import { sendeEmail, escapeHtmlFuerEmail } from "./email.js";
|
||||
// @ts-ignore
|
||||
import gutscheineRaw from "../../src/content/gutscheine.json";
|
||||
|
||||
const ERLAUBTE_LAENDER = ["de", "at", "ch", "lu"];
|
||||
const gutscheine = (gutscheineRaw && gutscheineRaw.codes) || [];
|
||||
// VanVans Wunsch-Adresse für Bestellbenachrichtigungen. Fest hinterlegt statt als Secret, da es
|
||||
// keine geheime Information ist (nur ein Empfänger).
|
||||
const BENACHRICHTIGUNGS_EMAIL = "[email protected]";
|
||||
const LAND_NAME = { de: "Deutschland", at: "Österreich", ch: "Schweiz", lu: "Luxemburg" };
|
||||
|
||||
function formatPreis(n) {
|
||||
return `${Number(n).toFixed(2).replace(".", ",")} €`;
|
||||
}
|
||||
|
||||
function bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe, status }) {
|
||||
const artikelZeilen = artikel
|
||||
.map((a) => `<li>${a.menge}× ${escapeHtmlFuerEmail(a.name)} — ${formatPreis(a.preis * a.menge)}</li>`)
|
||||
.join("");
|
||||
const statusZeile =
|
||||
status === "zahlungOffen"
|
||||
? `<p style="color:#b06a00;"><strong>⏳ Zahlung noch ausstehend</strong> — Überweisung, bitte Zahlungseingang abwarten, bevor die Ware verschickt wird.</p>`
|
||||
: `<p style="color:#1f9254;"><strong>✅ Zahlung bestätigt</strong> (PayPal)</p>`;
|
||||
return `
|
||||
<div style="font-family: sans-serif; color: #1a1a1a; line-height: 1.6;">
|
||||
<h2 style="margin: 0 0 0.5em;">🩵 Neue Bestellung ${escapeHtmlFuerEmail(bestellnummer)}</h2>
|
||||
${statusZeile}
|
||||
<p><strong>Von:</strong> ${escapeHtmlFuerEmail(name)} (${escapeHtmlFuerEmail(email)})<br />
|
||||
<strong>Land:</strong> ${escapeHtmlFuerEmail(LAND_NAME[land] || land)}</p>
|
||||
<p><strong>Artikel:</strong></p>
|
||||
<ul>${artikelZeilen}</ul>
|
||||
<p><strong>Summe: ${formatPreis(summe)}</strong></p>
|
||||
<p><a href="https://vans-diy-bastelbedarf.pages.dev/verwaltung/">→ Zur Bestellverwaltung</a></p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function findeGutschein(code) {
|
||||
const normalisiert = String(code || "").trim().toUpperCase();
|
||||
if (!normalisiert) return null;
|
||||
return gutscheine.find((g) => String(g.code || "").toUpperCase() === normalisiert) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert die Bestelldaten, legt sie in D1 an und verschickt die Benachrichtigungsmail.
|
||||
* @param {*} env Cloudflare-Umgebung (env.DB etc.)
|
||||
* @param {*} body Roh-Payload vom Checkout (gleiches Format wie bisher)
|
||||
* @param {{status: "zahlungOffen"|"bezahlt", paypalOrderId?: string|null}} optionen
|
||||
*/
|
||||
export async function erstelleBestellung(env, body, optionen) {
|
||||
const { status, paypalOrderId = null } = optionen;
|
||||
|
||||
if (!env.DB) {
|
||||
return { ok: false, httpStatus: 500, error: "Datenbank nicht verbunden." };
|
||||
}
|
||||
|
||||
const land = String(body.land || "").toLowerCase();
|
||||
if (!ERLAUBTE_LAENDER.includes(land)) {
|
||||
return { ok: false, httpStatus: 400, error: "Ungültiges Land." };
|
||||
}
|
||||
|
||||
const kunde = body.kunde && typeof body.kunde === "object" ? body.kunde : {};
|
||||
const name = String(kunde.name || "").trim();
|
||||
const email = String(kunde.email || "").trim();
|
||||
if (!name || !email || !email.includes("@")) {
|
||||
return { ok: false, httpStatus: 400, error: "Ungültige Kundendaten." };
|
||||
}
|
||||
|
||||
const artikel = Array.isArray(body.artikel) ? body.artikel : [];
|
||||
if (artikel.length === 0) {
|
||||
return { ok: false, httpStatus: 400, error: "Die Bestellung enthält keine Artikel." };
|
||||
}
|
||||
for (const a of artikel) {
|
||||
if (!a || typeof a.slug !== "string" || !a.slug.trim() || typeof a.name !== "string" || !a.name.trim()) {
|
||||
return { ok: false, httpStatus: 400, error: "Ungültige Artikeldaten." };
|
||||
}
|
||||
if (!Number.isFinite(a.menge) || a.menge <= 0) {
|
||||
return { ok: false, httpStatus: 400, error: "Ungültige Artikelmenge." };
|
||||
}
|
||||
if (!Number.isFinite(a.preis) || a.preis < 0) {
|
||||
return { ok: false, httpStatus: 400, error: "Ungültiger Artikelpreis." };
|
||||
}
|
||||
}
|
||||
|
||||
const summe = Number(body.summe);
|
||||
if (!Number.isFinite(summe) || summe < 0) {
|
||||
return { ok: false, httpStatus: 400, error: "Ungültige Bestellsumme." };
|
||||
}
|
||||
const zwischensumme = Number.isFinite(Number(body.zwischensumme)) ? Number(body.zwischensumme) : 0;
|
||||
const versandkosten = Number.isFinite(Number(body.versandkosten)) ? Number(body.versandkosten) : 0;
|
||||
const rabattGesamt = Number.isFinite(Number(body.rabattGesamt)) ? Number(body.rabattGesamt) : 0;
|
||||
|
||||
// ── "Nur einmal pro Person" durchsetzen — unabhängig vom Browser/Gerät, da hier gegen die
|
||||
// echten, gespeicherten Bestellungen geprüft wird. Stornierte Bestellungen zählen NICHT als
|
||||
// "schon benutzt". ──
|
||||
const gutscheinCodeRoh = typeof body.gutscheinCode === "string" ? body.gutscheinCode.trim() : "";
|
||||
let gutscheinCodeNormalisiert = null;
|
||||
if (gutscheinCodeRoh) {
|
||||
const gutschein = findeGutschein(gutscheinCodeRoh);
|
||||
gutscheinCodeNormalisiert = gutscheinCodeRoh.toUpperCase();
|
||||
if (gutschein && gutschein.einmaligProPerson) {
|
||||
const { results } = await env.DB.prepare(
|
||||
`SELECT COUNT(*) as anzahl FROM orders WHERE gutschein_code = ? AND LOWER(kunde_email) = LOWER(?) AND status != 'storniert'`
|
||||
)
|
||||
.bind(gutscheinCodeNormalisiert, email)
|
||||
.all();
|
||||
if ((results?.[0]?.anzahl ?? 0) > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
httpStatus: 400,
|
||||
error: "Dieser Gutscheincode wurde bereits mit dieser E-Mail-Adresse eingelöst und gilt nur einmal pro Person.",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const insertOrder = await env.DB.prepare(
|
||||
`INSERT INTO orders
|
||||
(created_at, updated_at, status, kunde_name, kunde_email, land, strasse, plz, ort,
|
||||
zahlungsart, zwischensumme, versandkosten, rabatt_gesamt, summe,
|
||||
treuebonus_verwendet, abo_rabatt_verwendet, gutschein_code, paypal_order_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||
)
|
||||
.bind(
|
||||
now,
|
||||
now,
|
||||
status,
|
||||
name,
|
||||
email,
|
||||
land,
|
||||
String(kunde.strasse || ""),
|
||||
String(kunde.plz || ""),
|
||||
String(kunde.ort || ""),
|
||||
String(body.zahlungsart || ""),
|
||||
zwischensumme,
|
||||
versandkosten,
|
||||
rabattGesamt,
|
||||
summe,
|
||||
body.treuebonusVerwendet ? 1 : 0,
|
||||
body.aboRabattVerwendet ? 1 : 0,
|
||||
gutscheinCodeNormalisiert,
|
||||
paypalOrderId
|
||||
)
|
||||
.run();
|
||||
|
||||
const orderId = insertOrder.meta.last_row_id;
|
||||
// Menschenlesbare Bestellnummer wird ERST NACH dem Insert aus der autoinkrementierten,
|
||||
// garantiert eindeutigen ID abgeleitet — keine Race Condition bei zwei gleichzeitigen
|
||||
// Bestellungen.
|
||||
const jahr = new Date().getFullYear();
|
||||
const bestellnummer = `VDB-${jahr}-${String(orderId).padStart(4, "0")}`;
|
||||
await env.DB.prepare(`UPDATE orders SET bestellnummer = ? WHERE id = ?`).bind(bestellnummer, orderId).run();
|
||||
|
||||
const itemStmts = artikel.map((a) =>
|
||||
env.DB.prepare(
|
||||
`INSERT INTO order_items (order_id, produkt_slug, name, kategorie_slug, menge, preis, gratis)
|
||||
VALUES (?,?,?,?,?,?,?)`
|
||||
).bind(orderId, a.slug, a.name, a.kategorie || null, Math.round(a.menge), a.preis, a.gratis ? 1 : 0)
|
||||
);
|
||||
await env.DB.batch(itemStmts);
|
||||
|
||||
// E-Mail-Benachrichtigung — bewusst abgewartet (siehe functions/_shared/email.js), aber
|
||||
// NICHT fehlerkritisch: schlägt sie fehl, bleibt die Bestellung trotzdem ganz normal
|
||||
// gespeichert.
|
||||
await sendeEmail(env, {
|
||||
to: BENACHRICHTIGUNGS_EMAIL,
|
||||
subject: `${status === "zahlungOffen" ? "⏳" : "🩵"} Neue Bestellung ${bestellnummer}`,
|
||||
html: bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe, status }),
|
||||
text: `Neue Bestellung ${bestellnummer} von ${name} (${email}), ${artikel.length} Artikel, Summe ${formatPreis(summe)}. Status: ${status === "zahlungOffen" ? "Zahlung ausstehend (Überweisung)" : "Bezahlt (PayPal)"}. Details: https://vans-diy-bastelbedarf.pages.dev/verwaltung/`,
|
||||
});
|
||||
|
||||
return { ok: true, id: orderId, bestellnummer };
|
||||
} catch (err) {
|
||||
return { ok: false, httpStatus: 500, error: "Bestellung konnte nicht gespeichert werden." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/* =====================================================================
|
||||
functions/_shared/paypal.js — echte, server-seitige PayPal-Anbindung (REST API v2).
|
||||
|
||||
WARUM SERVER-SEITIG UND NICHT NUR IM BROWSER: Die vorherige Lösung ließ den Browser die
|
||||
Zahlung direkt mit PayPal abwickeln (`actions.order.capture()`) und hat dem Ergebnis blind
|
||||
vertraut — wer wollte, hätte den JavaScript-Aufruf einfach überspringen und trotzdem eine
|
||||
"erfolgreiche" Bestellung auslösen können, OHNE je wirklich zu bezahlen. Das wäre ein echtes
|
||||
Betrugsrisiko für VanVan gewesen (Ware raus, kein Geld rein).
|
||||
|
||||
Jetzt läuft die komplette Zahlungsprüfung hier auf dem Server:
|
||||
1. functions/api/paypal/create-order.js legt die PayPal-Bestellung server-seitig an (der
|
||||
Betrag kommt also von UNS, nicht vom Browser).
|
||||
2. functions/api/paypal/capture-order.js fragt NACH der PayPal-Bestätigung direkt bei PayPal
|
||||
selbst nach: ist die Zahlung wirklich abgeschlossen (`status === "COMPLETED"`) und stimmt
|
||||
der tatsächlich eingezogene Betrag mit der Bestellsumme überein? Nur wenn beides zutrifft,
|
||||
wird die Bestellung in der Datenbank als "bezahlt" angelegt.
|
||||
|
||||
Zugangsdaten: env.PAYPAL_CLIENT_ID / env.PAYPAL_CLIENT_SECRET (Cloudflare-Pages-Secrets,
|
||||
niemals im Code). env.PAYPAL_ENV steuert Sandbox ("sandbox", Standard/Test) vs. Live-Betrieb
|
||||
("live") — sobald VanVan ein echtes PayPal-Business-Konto hat, reicht das Setzen der echten
|
||||
Live-Zugangsdaten + PAYPAL_ENV=live, ohne Codeänderung.
|
||||
===================================================================== */
|
||||
|
||||
function paypalBasisUrl(env) {
|
||||
return env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
|
||||
}
|
||||
|
||||
/** Prüft, ob PayPal überhaupt eingerichtet ist — solange nicht, sollen alle PayPal-Routen sauber
|
||||
* einen klaren Fehler liefern statt kryptisch gegen "undefined"-Zugangsdaten zu laufen. */
|
||||
export function paypalKonfiguriert(env) {
|
||||
return !!(env.PAYPAL_CLIENT_ID && env.PAYPAL_CLIENT_SECRET);
|
||||
}
|
||||
|
||||
/** Holt ein kurzlebiges OAuth-Zugangstoken von PayPal (Client-Credentials-Flow, Standard-REST-
|
||||
* API-Authentifizierung — kein Nutzer-Login nötig, das ist reine Server-zu-Server-Kommunikation
|
||||
* mit VanVans eigenen App-Zugangsdaten). */
|
||||
async function holeZugangstoken(env) {
|
||||
const res = await fetch(`${paypalBasisUrl(env)}/v1/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${btoa(`${env.PAYPAL_CLIENT_ID}:${env.PAYPAL_CLIENT_SECRET}`)}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`PayPal-Zugangstoken konnte nicht geholt werden (Status ${res.status}): ${text}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
/** Legt eine echte PayPal-Bestellung an — der Betrag kommt bewusst von UNS (server-seitig
|
||||
* berechnet aus dem Warenkorb), nicht vom Browser, damit er beim Bezahlen bei PayPal nicht
|
||||
* manipuliert werden kann. Gibt die PayPal-Bestell-ID zurück, die der Browser dann dem
|
||||
* PayPal-Smart-Button übergibt. */
|
||||
export async function erstellePaypalBestellung(env, betragEuro) {
|
||||
const token = await holeZugangstoken(env);
|
||||
const res = await fetch(`${paypalBasisUrl(env)}/v2/checkout/orders`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
intent: "CAPTURE",
|
||||
purchase_units: [{ amount: { currency_code: "EUR", value: Math.max(0.01, betragEuro).toFixed(2) } }],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`PayPal-Bestellung konnte nicht angelegt werden (Status ${res.status}): ${text}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.id;
|
||||
}
|
||||
|
||||
/** Zieht die Zahlung für eine zuvor angelegte PayPal-Bestellung tatsächlich ein und prüft das
|
||||
* Ergebnis direkt bei PayPal (nicht nur, was der Browser behauptet). Gibt
|
||||
* { ok, captureId, betragEuro } zurück — "ok" ist NUR true, wenn PayPal selbst "COMPLETED"
|
||||
* bestätigt. Wirft nie einen Fehler nach außen (siehe Muster in functions/_shared/email.js),
|
||||
* der Aufrufer prüft einfach `.ok`. */
|
||||
export async function erfassePaypalZahlung(env, paypalOrderId) {
|
||||
try {
|
||||
const token = await holeZugangstoken(env);
|
||||
const res = await fetch(`${paypalBasisUrl(env)}/v2/checkout/orders/${encodeURIComponent(paypalOrderId)}/capture`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok || !data) {
|
||||
return { ok: false, error: `PayPal-Zahlung konnte nicht erfasst werden (Status ${res.status}).` };
|
||||
}
|
||||
// Manche Zahlungen (z.B. bei zusätzlicher Prüfung durch die Bank) landen nicht sofort auf
|
||||
// COMPLETED — das behandeln wir bewusst als "nicht erfolgreich", damit nie eine Bestellung
|
||||
// als bezahlt gilt, obwohl das Geld noch nicht sicher zugesagt ist.
|
||||
const capture = data?.purchase_units?.[0]?.payments?.captures?.[0];
|
||||
if (data.status !== "COMPLETED" || !capture || capture.status !== "COMPLETED") {
|
||||
return { ok: false, error: `PayPal-Zahlung ist nicht abgeschlossen (Status: ${data.status}).` };
|
||||
}
|
||||
return { ok: true, captureId: capture.id, betragEuro: Number(capture.amount?.value ?? 0) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
+25
-183
@@ -1,69 +1,24 @@
|
||||
/* =====================================================================
|
||||
POST /api/orders — legt eine echte Bestellung in D1 an (orders + order_items).
|
||||
POST /api/orders — legt eine Überweisungs-Bestellung (Vorkasse) in D1 an.
|
||||
|
||||
Wird vom Checkout aufgerufen (siehe src/pages/checkout/index.astro), sobald jemand die
|
||||
Bestellung abschickt. BEWUSST nicht dauerhaft hinter dem Zugangscode-Gate (siehe
|
||||
Wird vom Checkout aufgerufen (siehe src/pages/checkout/index.astro), sobald jemand mit
|
||||
"Banküberweisung" bestellt. BEWUSST nicht dauerhaft hinter dem Zugangscode-Gate (siehe
|
||||
functions/_middleware.js) — sobald der Shop öffentlich ist (SITE_PUBLIC=true), müssen auch
|
||||
echte Kund:innen hier bestellen können, genau wie sie den Checkout selbst erreichen können.
|
||||
Vor dem Live-Gang ist ohnehin die ganze Seite inkl. Checkout hinter dem Gate, d.h. nur
|
||||
qciga/VanVan können bis hierhin überhaupt kommen.
|
||||
echte Kund:innen hier bestellen können.
|
||||
|
||||
In Phase 1 gibt es kein echtes Zahlungs-Backend — jede vollständig abgeschickte Bestellung
|
||||
gilt sofort als "bezahlt" (siehe scripts/account.ts, derselbe Grundsatz). ===================================================================== */
|
||||
WICHTIG (seit der echten PayPal-Anbindung): Diese Route legt Bestellungen NUR noch mit dem
|
||||
Status "zahlungOffen" an (Geld ist noch nicht da) und lehnt zahlungsart:"paypal" AUSDRÜCKLICH
|
||||
ab. Grund: PayPal-Bestellungen dürfen NUR über functions/api/paypal/capture-order.js entstehen,
|
||||
nachdem die Zahlung dort server-seitig bei PayPal selbst bestätigt wurde — sonst könnte jemand
|
||||
diese Route einfach direkt mit zahlungsart:"paypal" aufrufen und eine "bezahlte" Bestellung
|
||||
vortäuschen, ohne je wirklich zu bezahlen (echtes Betrugs-/Verlustrisiko für VanVan). ===================================================================== */
|
||||
|
||||
import { json } from "../_shared/http.js";
|
||||
import { sendeEmail, escapeHtmlFuerEmail } from "../_shared/email.js";
|
||||
// Gleiche Gutschein-Daten wie im Frontend (src/data/gutscheine.ts) — hier server-seitig erneut
|
||||
// eingebunden, um das "nur einmal pro Person"-Flag unabhängig vom Browser durchzusetzen (siehe
|
||||
// unten). Bewusst derselbe JSON-Rohdaten-Import, keine Code-Duplizierung der Inhalte.
|
||||
// @ts-ignore
|
||||
import gutscheineRaw from "../../src/content/gutscheine.json";
|
||||
|
||||
const ERLAUBTE_LAENDER = ["de", "at", "ch", "lu"];
|
||||
const gutscheine = (gutscheineRaw && gutscheineRaw.codes) || [];
|
||||
// VanVans Wunsch-Adresse für Bestellbenachrichtigungen (siehe Anfrage vom 03.08.2026). Fest
|
||||
// hinterlegt statt als Secret, da es keine geheime Information ist (nur ein Empfänger).
|
||||
const BENACHRICHTIGUNGS_EMAIL = "[email protected]";
|
||||
|
||||
const LAND_NAME = { de: "Deutschland", at: "Österreich", ch: "Schweiz", lu: "Luxemburg" };
|
||||
|
||||
function formatPreis(n) {
|
||||
return `${Number(n).toFixed(2).replace(".", ",")} €`;
|
||||
}
|
||||
|
||||
/** Baut die interne "neue Bestellung eingegangen"-E-Mail an VanVan/qciga — bewusst schlicht
|
||||
* gehalten (kein Marketing-Layout nötig, nur die wichtigsten Infos auf einen Blick + Link zur
|
||||
* Verwaltung). */
|
||||
function bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe }) {
|
||||
const artikelZeilen = artikel
|
||||
.map((a) => `<li>${a.menge}× ${escapeHtmlFuerEmail(a.name)} — ${formatPreis(a.preis * a.menge)}</li>`)
|
||||
.join("");
|
||||
return `
|
||||
<div style="font-family: sans-serif; color: #1a1a1a; line-height: 1.6;">
|
||||
<h2 style="margin: 0 0 0.5em;">🩵 Neue Bestellung ${escapeHtmlFuerEmail(bestellnummer)}</h2>
|
||||
<p><strong>Von:</strong> ${escapeHtmlFuerEmail(name)} (${escapeHtmlFuerEmail(email)})<br />
|
||||
<strong>Land:</strong> ${escapeHtmlFuerEmail(LAND_NAME[land] || land)}</p>
|
||||
<p><strong>Artikel:</strong></p>
|
||||
<ul>${artikelZeilen}</ul>
|
||||
<p><strong>Summe: ${formatPreis(summe)}</strong></p>
|
||||
<p><a href="https://vans-diy-bastelbedarf.pages.dev/verwaltung/">→ Zur Bestellverwaltung</a></p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function findeGutschein(code) {
|
||||
const normalisiert = String(code || "").trim().toUpperCase();
|
||||
if (!normalisiert) return null;
|
||||
return gutscheine.find((g) => String(g.code || "").toUpperCase() === normalisiert) || null;
|
||||
}
|
||||
import { erstelleBestellung } from "../_shared/bestellung-erstellen.js";
|
||||
|
||||
export async function onRequestPost(context) {
|
||||
const { request, env } = context;
|
||||
|
||||
if (!env.DB) {
|
||||
return json(500, { ok: false, error: "Datenbank nicht verbunden." });
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
@@ -71,133 +26,20 @@ export async function onRequestPost(context) {
|
||||
return json(400, { ok: false, error: "Ungültige Anfrage." });
|
||||
}
|
||||
|
||||
// ── Validierung: lieber eine klare Fehlermeldung im Checkout als eine kaputte/leere
|
||||
// Bestellung in der Datenbank. ──
|
||||
const land = String(body.land || "").toLowerCase();
|
||||
if (!ERLAUBTE_LAENDER.includes(land)) {
|
||||
return json(400, { ok: false, error: "Ungültiges Land." });
|
||||
}
|
||||
|
||||
const kunde = body.kunde && typeof body.kunde === "object" ? body.kunde : {};
|
||||
const name = String(kunde.name || "").trim();
|
||||
const email = String(kunde.email || "").trim();
|
||||
if (!name || !email || !email.includes("@")) {
|
||||
return json(400, { ok: false, error: "Ungültige Kundendaten." });
|
||||
}
|
||||
|
||||
const artikel = Array.isArray(body.artikel) ? body.artikel : [];
|
||||
if (artikel.length === 0) {
|
||||
return json(400, { ok: false, error: "Die Bestellung enthält keine Artikel." });
|
||||
}
|
||||
for (const a of artikel) {
|
||||
if (!a || typeof a.slug !== "string" || !a.slug.trim() || typeof a.name !== "string" || !a.name.trim()) {
|
||||
return json(400, { ok: false, error: "Ungültige Artikeldaten." });
|
||||
}
|
||||
if (!Number.isFinite(a.menge) || a.menge <= 0) {
|
||||
return json(400, { ok: false, error: "Ungültige Artikelmenge." });
|
||||
}
|
||||
if (!Number.isFinite(a.preis) || a.preis < 0) {
|
||||
return json(400, { ok: false, error: "Ungültiger Artikelpreis." });
|
||||
}
|
||||
}
|
||||
|
||||
const summe = Number(body.summe);
|
||||
if (!Number.isFinite(summe) || summe < 0) {
|
||||
return json(400, { ok: false, error: "Ungültige Bestellsumme." });
|
||||
}
|
||||
const zwischensumme = Number.isFinite(Number(body.zwischensumme)) ? Number(body.zwischensumme) : 0;
|
||||
const versandkosten = Number.isFinite(Number(body.versandkosten)) ? Number(body.versandkosten) : 0;
|
||||
const rabattGesamt = Number.isFinite(Number(body.rabattGesamt)) ? Number(body.rabattGesamt) : 0;
|
||||
|
||||
// ── "Nur einmal pro Person" durchsetzen — unabhängig vom Browser/Gerät, da hier gegen die
|
||||
// echten, gespeicherten Bestellungen geprüft wird (siehe d1/schema.sql, Spalte
|
||||
// "gutschein_code"). Stornierte Bestellungen zählen NICHT als "schon benutzt" — wurde ja
|
||||
// letztlich nicht wirksam eingelöst. ──
|
||||
const gutscheinCodeRoh = typeof body.gutscheinCode === "string" ? body.gutscheinCode.trim() : "";
|
||||
let gutscheinCodeNormalisiert = null;
|
||||
if (gutscheinCodeRoh) {
|
||||
const gutschein = findeGutschein(gutscheinCodeRoh);
|
||||
gutscheinCodeNormalisiert = gutscheinCodeRoh.toUpperCase();
|
||||
if (gutschein && gutschein.einmaligProPerson) {
|
||||
const { results } = await env.DB.prepare(
|
||||
`SELECT COUNT(*) as anzahl FROM orders WHERE gutschein_code = ? AND LOWER(kunde_email) = LOWER(?) AND status != 'storniert'`
|
||||
)
|
||||
.bind(gutscheinCodeNormalisiert, email)
|
||||
.all();
|
||||
if ((results?.[0]?.anzahl ?? 0) > 0) {
|
||||
return json(400, {
|
||||
ok: false,
|
||||
error: "Dieser Gutscheincode wurde bereits mit dieser E-Mail-Adresse eingelöst und gilt nur einmal pro Person.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const insertOrder = await env.DB.prepare(
|
||||
`INSERT INTO orders
|
||||
(created_at, updated_at, status, kunde_name, kunde_email, land, strasse, plz, ort,
|
||||
zahlungsart, zwischensumme, versandkosten, rabatt_gesamt, summe,
|
||||
treuebonus_verwendet, abo_rabatt_verwendet, gutschein_code)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||
)
|
||||
.bind(
|
||||
now,
|
||||
now,
|
||||
"bezahlt",
|
||||
name,
|
||||
email,
|
||||
land,
|
||||
String(kunde.strasse || ""),
|
||||
String(kunde.plz || ""),
|
||||
String(kunde.ort || ""),
|
||||
String(body.zahlungsart || ""),
|
||||
zwischensumme,
|
||||
versandkosten,
|
||||
rabattGesamt,
|
||||
summe,
|
||||
body.treuebonusVerwendet ? 1 : 0,
|
||||
body.aboRabattVerwendet ? 1 : 0,
|
||||
gutscheinCodeNormalisiert
|
||||
)
|
||||
.run();
|
||||
|
||||
const orderId = insertOrder.meta.last_row_id;
|
||||
// Menschenlesbare Bestellnummer wird ERST NACH dem Insert aus der autoinkrementierten,
|
||||
// garantiert eindeutigen ID abgeleitet — keine Race Condition bei zwei gleichzeitigen
|
||||
// Bestellungen (anders als z.B. "COUNT(*) + 1" vorher zu berechnen).
|
||||
const jahr = new Date().getFullYear();
|
||||
const bestellnummer = `VDB-${jahr}-${String(orderId).padStart(4, "0")}`;
|
||||
await env.DB.prepare(`UPDATE orders SET bestellnummer = ? WHERE id = ?`).bind(bestellnummer, orderId).run();
|
||||
|
||||
const itemStmts = artikel.map((a) =>
|
||||
env.DB.prepare(
|
||||
`INSERT INTO order_items (order_id, produkt_slug, name, kategorie_slug, menge, preis, gratis)
|
||||
VALUES (?,?,?,?,?,?,?)`
|
||||
).bind(orderId, a.slug, a.name, a.kategorie || null, Math.round(a.menge), a.preis, a.gratis ? 1 : 0)
|
||||
);
|
||||
await env.DB.batch(itemStmts);
|
||||
|
||||
// E-Mail-Benachrichtigung an VanVan/qciga — wird bewusst ABGEWARTET (nicht per
|
||||
// context.waitUntil im Hintergrund verschickt): ein Test am 03.08.2026 hat gezeigt, dass
|
||||
// waitUntil() in dieser Pages-Functions-Umgebung nicht zuverlässig zu Ende lief, bevor die
|
||||
// Anfrage beendet wurde (die Bestellung wurde gespeichert, aber die E-Mail kam nie an). Die
|
||||
// paar hundert Millisekunden zusätzliche Wartezeit beim Abschicken der Bestellung sind ein
|
||||
// kleiner Preis für "die E-Mail kommt garantiert an". Trotzdem weiterhin NICHT
|
||||
// fehlerkritisch: sendeEmail() wirft nie einen Fehler, schlägt der Versand fehl (z.B. weil
|
||||
// kein RESEND_API_KEY hinterlegt ist), bleibt die Bestellung trotzdem ganz normal gespeichert
|
||||
// und in /verwaltung/ sichtbar — nur die Benachrichtigung fehlt dann eben.
|
||||
await sendeEmail(env, {
|
||||
to: BENACHRICHTIGUNGS_EMAIL,
|
||||
subject: `🩵 Neue Bestellung ${bestellnummer}`,
|
||||
html: bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe }),
|
||||
text: `Neue Bestellung ${bestellnummer} von ${name} (${email}), ${artikel.length} Artikel, Summe ${formatPreis(summe)}. Details: https://vans-diy-bastelbedarf.pages.dev/verwaltung/`,
|
||||
const zahlungsart = String(body.zahlungsart || "");
|
||||
if (zahlungsart === "paypal") {
|
||||
return json(400, {
|
||||
ok: false,
|
||||
error: "PayPal-Bestellungen laufen über den PayPal-Button im Checkout, nicht über diese Route.",
|
||||
});
|
||||
|
||||
return json(201, { ok: true, id: orderId, bestellnummer });
|
||||
} catch (err) {
|
||||
return json(500, { ok: false, error: "Bestellung konnte nicht gespeichert werden." });
|
||||
}
|
||||
if (zahlungsart !== "ueberweisung") {
|
||||
return json(400, { ok: false, error: "Ungültige oder nicht unterstützte Zahlungsart." });
|
||||
}
|
||||
|
||||
const ergebnis = await erstelleBestellung(env, body, { status: "zahlungOffen" });
|
||||
if (!ergebnis.ok) {
|
||||
return json(ergebnis.httpStatus || 500, { ok: false, error: ergebnis.error });
|
||||
}
|
||||
return json(201, { ok: true, id: ergebnis.id, bestellnummer: ergebnis.bestellnummer });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/* =====================================================================
|
||||
POST /api/paypal/capture-order — zieht eine zuvor angelegte PayPal-Zahlung EIN und legt die
|
||||
Bestellung erst danach an.
|
||||
|
||||
Das ist der entscheidende Sicherheits-Schritt der ganzen PayPal-Anbindung: Der Browser meldet
|
||||
hier "die Kundin hat bei PayPal auf Bezahlen geklickt" (`onApprove`-Callback), aber wir
|
||||
glauben das NICHT einfach — stattdessen fragen wir direkt bei PayPal selbst nach (server-zu-
|
||||
server, mit VanVans eigenen App-Zugangsdaten), ob die Zahlung wirklich abgeschlossen ist UND
|
||||
ob der eingezogene Betrag zur Bestellsumme passt. Nur wenn beides stimmt, wird die Bestellung
|
||||
in der Datenbank angelegt — mit Status "bezahlt" und der echten PayPal-Bestell-ID als Beleg.
|
||||
|
||||
Schlägt die Zahlungsprüfung fehl, wird NICHTS gespeichert — die Kundin sieht eine klare
|
||||
Fehlermeldung im Checkout und kann es erneut versuchen, statt dass VanVan eine "Bestellung"
|
||||
ohne echtes Geld dahinter bekommt. ===================================================================== */
|
||||
|
||||
import { json } from "../../_shared/http.js";
|
||||
import { paypalKonfiguriert, erfassePaypalZahlung } from "../../_shared/paypal.js";
|
||||
import { erstelleBestellung } from "../../_shared/bestellung-erstellen.js";
|
||||
|
||||
// Kleine Toleranz für Rundungsdifferenzen (Cent-Rundung zwischen unserer Berechnung und PayPals
|
||||
// eigener Darstellung) — alles darüber gilt als verdächtige Abweichung und wird abgelehnt.
|
||||
const BETRAG_TOLERANZ_EURO = 0.02;
|
||||
|
||||
export async function onRequestPost(context) {
|
||||
const { request, env } = context;
|
||||
|
||||
if (!paypalKonfiguriert(env)) {
|
||||
return json(503, { ok: false, error: "PayPal ist auf dieser Seite noch nicht eingerichtet." });
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json(400, { ok: false, error: "Ungültige Anfrage." });
|
||||
}
|
||||
|
||||
const paypalOrderId = typeof body.paypalOrderId === "string" ? body.paypalOrderId.trim() : "";
|
||||
if (!paypalOrderId) {
|
||||
return json(400, { ok: false, error: "Fehlende PayPal-Bestell-ID." });
|
||||
}
|
||||
|
||||
const summe = Number(body.summe);
|
||||
if (!Number.isFinite(summe) || summe <= 0) {
|
||||
return json(400, { ok: false, error: "Ungültige Bestellsumme." });
|
||||
}
|
||||
|
||||
// ── Schritt 1: Zahlung server-seitig bei PayPal einziehen + verifizieren. ──
|
||||
const zahlung = await erfassePaypalZahlung(env, paypalOrderId);
|
||||
if (!zahlung.ok) {
|
||||
return json(402, { ok: false, error: `Zahlung konnte nicht bestätigt werden: ${zahlung.error}` });
|
||||
}
|
||||
|
||||
// ── Schritt 2: Betrag gegenprüfen — verhindert, dass hier ein anderer (z.B. kleinerer) Betrag
|
||||
// als der tatsächlich fällige "durchgeschmuggelt" wird. ──
|
||||
if (Math.abs(zahlung.betragEuro - summe) > BETRAG_TOLERANZ_EURO) {
|
||||
return json(402, {
|
||||
ok: false,
|
||||
error: `Der bei PayPal gezahlte Betrag (${zahlung.betragEuro.toFixed(2)} €) stimmt nicht mit der Bestellsumme (${summe.toFixed(2)} €) überein.`,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Schritt 3: Erst JETZT, mit bestätigter echter Zahlung, die Bestellung anlegen. ──
|
||||
const ergebnis = await erstelleBestellung(env, { ...body, zahlungsart: "paypal" }, {
|
||||
status: "bezahlt",
|
||||
paypalOrderId,
|
||||
});
|
||||
if (!ergebnis.ok) {
|
||||
// Die Zahlung ist zu diesem Zeitpunkt bereits bei PayPal eingezogen, konnte aber nicht
|
||||
// gespeichert werden (z.B. D1 kurzzeitig nicht erreichbar) — das ist ein seltener, aber
|
||||
// ernster Fall: VanVan hat das Geld, aber keine Bestellung in der Verwaltung. Deshalb bekommt
|
||||
// die Kundin eine Meldung, die sie NICHT zum erneuten Bezahlen auffordert, sondern zur
|
||||
// Kontaktaufnahme, um Doppelzahlungen zu vermeiden.
|
||||
return json(500, {
|
||||
ok: false,
|
||||
error: `Deine Zahlung wurde erfolgreich bei PayPal abgebucht (Beleg-ID: ${zahlung.captureId}), aber die Bestellung konnte nicht gespeichert werden. Bitte kontaktiere uns mit dieser Beleg-ID, damit wir das von Hand nachtragen — bitte NICHT erneut bezahlen.`,
|
||||
});
|
||||
}
|
||||
|
||||
return json(201, { ok: true, id: ergebnis.id, bestellnummer: ergebnis.bestellnummer });
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/* =====================================================================
|
||||
POST /api/paypal/create-order — legt eine echte PayPal-Bestellung server-seitig an.
|
||||
|
||||
Wird vom Checkout aufgerufen, bevor der PayPal-Smart-Button überhaupt anzeigt, was zu bezahlen
|
||||
ist (`createOrder`-Callback des PayPal-JS-SDK). Der Betrag kommt bewusst von UNS (aus dem im
|
||||
Checkout berechneten Gesamtbetrag, den der Browser hier mitschickt) und wird 1:1 an PayPal
|
||||
weitergereicht — die eigentliche Sicherheit entsteht erst beim Erfassen der Zahlung (siehe
|
||||
capture-order.js), wo der TATSÄCHLICH bei PayPal eingezogene Betrag serverseitig geprüft wird.
|
||||
===================================================================== */
|
||||
|
||||
import { json } from "../../_shared/http.js";
|
||||
import { paypalKonfiguriert, erstellePaypalBestellung } from "../../_shared/paypal.js";
|
||||
|
||||
export async function onRequestPost(context) {
|
||||
const { request, env } = context;
|
||||
|
||||
if (!paypalKonfiguriert(env)) {
|
||||
return json(503, {
|
||||
ok: false,
|
||||
error: "PayPal ist auf dieser Seite noch nicht eingerichtet (fehlende Zugangsdaten). Bitte eine andere Zahlungsart wählen.",
|
||||
});
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json(400, { ok: false, error: "Ungültige Anfrage." });
|
||||
}
|
||||
|
||||
const summe = Number(body.summe);
|
||||
if (!Number.isFinite(summe) || summe <= 0) {
|
||||
return json(400, { ok: false, error: "Ungültiger Betrag." });
|
||||
}
|
||||
|
||||
try {
|
||||
const paypalOrderId = await erstellePaypalBestellung(env, summe);
|
||||
return json(200, { ok: true, id: paypalOrderId });
|
||||
} catch (err) {
|
||||
return json(502, { ok: false, error: err instanceof Error ? err.message : "PayPal-Bestellung fehlgeschlagen." });
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
import { getGateRole, unauthorizedJson } from "../../../_shared/auth.js";
|
||||
import { json } from "../../../_shared/http.js";
|
||||
|
||||
const ERLAUBTE_STATUS = ["bezahlt", "bearbeitung", "versandVorbereitet", "versendet", "abgeschlossen", "storniert"];
|
||||
const ERLAUBTE_STATUS = ["zahlungOffen", "bezahlt", "bearbeitung", "versandVorbereitet", "versendet", "abgeschlossen", "storniert"];
|
||||
|
||||
export async function onRequestPatch(context) {
|
||||
const { request, env, params } = context;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
{
|
||||
"heading": "§ 4 Zahlung",
|
||||
"body": "Die Zahlung erfolgt wahlweise per PayPal, Klarna, Kreditkarte (Visa & Mastercard) oder Banküberweisung (Vorkasse)."
|
||||
"body": "Die Zahlung erfolgt wahlweise per PayPal (auch als Gast per Kredit-/Debitkarte, ohne eigenes PayPal-Konto) oder Banküberweisung (Vorkasse). Bei Vorkasse wird die Ware erst nach Zahlungseingang versendet."
|
||||
},
|
||||
{
|
||||
"heading": "§ 5 Lieferung",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
{
|
||||
"heading": "5. Zahlungsdienstleister",
|
||||
"body": "Je nach gewählter Zahlungsart geben wir Daten an die eingesetzten Zahlungsdienstleister weiter (PayPal, Klarna, Kreditkartenanbieter). Es gelten deren jeweils eigene Datenschutzhinweise. Bei Banküberweisung werden Daten an unser kontoführendes Kreditinstitut übermittelt."
|
||||
"body": "Bei Zahlung per PayPal geben wir die für die Zahlungsabwicklung erforderlichen Daten (u. a. Name, Anschrift, Bestellsumme) an die PayPal (Europe) S.à r.l. et Cie, S.C.A., 22-24 Boulevard Royal, L-2449 Luxembourg, weiter. PayPal verarbeitet diese Daten teilweise auch in den USA über die Muttergesellschaft PayPal, Inc.; PayPal ist nach dem EU-US Data Privacy Framework zertifiziert, das ein angemessenes Datenschutzniveau sicherstellt. Es gelten zusätzlich PayPals eigene Datenschutzhinweise (paypal.com/de/webapps/mpp/ua/privacy-full). Bei Banküberweisung werden Daten an unser kontoführendes Kreditinstitut übermittelt."
|
||||
},
|
||||
{
|
||||
"heading": "6. Kontaktformular",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{ "q": "Ab wann ist der Versand kostenlos?", "a": "Das hängt vom Lieferland ab: Deutschland ab 75 €, Österreich und Luxemburg ab 85 €, Schweiz ab 100 € Warenwert – dann versenden wir kostenfrei mit DHL. Darunter richten sich die Versandkosten nach Produkt, Land und Warenwert – Details dazu findest du auf der Seite Versand & Zahlung." },
|
||||
{ "q": "Kann ich Sonderwünsche oder individuelle Anfertigungen bestellen?", "a": "Ja, gerne! Schreib uns einfach über das Kontaktformular – wir schauen, was möglich ist." },
|
||||
{ "q": "Wie pflege ich meine handgemachten Produkte?", "a": "Die genauen Pflegehinweise stehen jeweils auf der Produktseite. Da unsere Stücke handgehäkelt bzw. handgestrickt sind, empfehlen wir grundsätzlich schonende Handwäsche bei niedriger Temperatur, keinen Trockner und liegend an der Luft trocknen, damit Form und Fasern erhalten bleiben." },
|
||||
{ "q": "Welche Zahlungsarten werden akzeptiert?", "a": "PayPal, Klarna, Kreditkarte (Visa & Mastercard) und Banküberweisung (Vorkasse)." },
|
||||
{ "q": "Welche Zahlungsarten werden akzeptiert?", "a": "PayPal (auch als Gast per Kredit-/Debitkarte, ohne eigenes PayPal-Konto) und Banküberweisung (Vorkasse)." },
|
||||
{ "q": "Kann ich meine Bestellung zurückgeben?", "a": "Es gilt das gesetzliche 14-tägige Widerrufsrecht (siehe unsere Widerrufsbelehrung). Bei individuell auf dich zugeschnittenen Sonderanfertigungen kann das Widerrufsrecht gesetzlich ausgeschlossen sein, bei Standardartikeln aus unserem Sortiment gilt es regulär." },
|
||||
{ "q": "Muss ich Umsatzsteuer zahlen?", "a": "Nein. Van's DIY & Bastelbedarf ist Kleinunternehmer gemäß §19 UStG – es wird keine Umsatzsteuer berechnet und ausgewiesen." },
|
||||
{ "q": "Brauche ich ein Kundenkonto zum Bestellen?", "a": "Nein, du kannst auch bequem als Gast bestellen. Ein Konto lohnt sich aber für Bestellhistorie, Wunschliste und Sendungsverfolgung an einem Ort." }
|
||||
@@ -14,7 +14,7 @@
|
||||
{ "q": "From when is shipping free?", "a": "It depends on the delivery country: Germany from €75, Austria and Luxembourg from €85, Switzerland from €100 order value – above that we ship free of charge via DHL. Below that, shipping costs depend on the product, country and order value – details on our Shipping & Payment page." },
|
||||
{ "q": "Can I request custom or personalized items?", "a": "Yes, gladly! Just reach out via the contact form – we'll see what's possible." },
|
||||
{ "q": "How do I care for my handmade items?", "a": "Care instructions are listed on the respective product page. As our pieces are hand-crocheted or hand-knitted, we generally recommend gentle hand-washing at a low temperature, no tumble dryer, and drying flat to preserve shape and fibres." },
|
||||
{ "q": "Which payment methods are accepted?", "a": "PayPal, Klarna, credit card (Visa & Mastercard) and bank transfer (prepayment)." },
|
||||
{ "q": "Which payment methods are accepted?", "a": "PayPal (also as a guest by credit/debit card, no PayPal account needed) and bank transfer (prepayment)." },
|
||||
{ "q": "Can I return my order?", "a": "The statutory 14-day right of withdrawal applies (see our withdrawal notice). For custom-made pieces tailored to you, the right of withdrawal may be excluded by law; for standard items from our range it applies as usual." },
|
||||
{ "q": "Do I have to pay VAT?", "a": "No. Van's DIY & Bastelbedarf is a small business per §19 UStG – no VAT is charged or shown." },
|
||||
{ "q": "Do I need an account to order?", "a": "No, you can conveniently check out as a guest. An account is worthwhile though for order history, wishlist and shipment tracking all in one place." }
|
||||
@@ -24,7 +24,7 @@
|
||||
{ "q": "Ab wenn isch dr Versand gratis?", "a": "Das chunnt uf's Liferland a: Dütschland ab 75 €, Öschterrych und Luxemburg ab 85 €, d'Schwiz ab 100 € Warewärt – drüber schicke mir gratis mit DHL. Drunder richte sich d'Versandchoschte nach Produkt, Land und Warewärt – Details dezue findsch uf dr Syte Versand & Zahlig." },
|
||||
{ "q": "Cha ich Sonderwünsch oder öppis Individuells bstelle?", "a": "Ja, gärn! Schrib eus eifach übers Kontaktformular – mir luege, was geit." },
|
||||
{ "q": "Wie pfläg ich mini vo Hang gmachte Sächeli?", "a": "D'gnaue Pflegehinwys stöh jewils uf dr Produktsyte. Wil eusi Sächeli vo Hang ghäklet oder gstrickt sind, empfähle mir grundsätzlich schonendi Handwösch bi niedriger Temperatur, kei Tumbler und liegend a dr Luft träechne, dass Form und Fase erhalte bliebe." },
|
||||
{ "q": "Weli Zahligsarte werded akzeptiert?", "a": "PayPal, Klarna, Kreditkarte (Visa & Mastercard) und Banküberwysig (Vorusszahlig)." },
|
||||
{ "q": "Weli Zahligsarte werded akzeptiert?", "a": "PayPal (au als Gast mit Kredit-/Debitkarte, ohni eigenes PayPal-Konto) und Banküberwysig (Vorusszahlig)." },
|
||||
{ "q": "Cha ich mini Bschtellig zrugg gäh?", "a": "Es gilt das gsetzlichi 14-tägig Widerrufsrächt (lueg üsi Widerrufsbelehrig aa). Bi Sondranfertigunge, wo speziell uf dich gmacht sind, cha das Widerrufsrächt gsetzlich usgschlosse si, bi Standardartikel us eusem Sortimänt gilt's regulär." },
|
||||
{ "q": "Muess ich Umsatzsteuer zahle?", "a": "Nei. Van's DIY & Bastelbedarf isch Kleinunternehmer gemäss §19 UStG – es wird kei Umsatzsteuer berechnet und usgwiese." },
|
||||
{ "q": "Bruuch ich es Kundekonto zum Bstelle?", "a": "Nei, du chasch au eifach als Gast bstelle. Es Konto lohnt sich aber für d'Bstellhistorie, d'Wunschlischte und d'Sändigsverfolgig a eim Ort." }
|
||||
@@ -34,7 +34,7 @@
|
||||
{ "q": "À partir de quand la livraison est-elle gratuite ?", "a": "Cela dépend du pays de livraison : Allemagne à partir de 75 €, Autriche et Luxembourg à partir de 85 €, Suisse à partir de 100 € d'achat – au-delà, nous livrons gratuitement via DHL. En dessous, les frais de port dépendent du produit, du pays et du montant de la commande – détails sur notre page Livraison & Paiement." },
|
||||
{ "q": "Puis-je commander une pièce personnalisée ?", "a": "Oui, avec plaisir ! Écrivez-nous simplement via le formulaire de contact – nous verrons ce qui est possible." },
|
||||
{ "q": "Comment entretenir mes produits faits main ?", "a": "Les instructions d'entretien figurent sur chaque page produit. Nos pièces étant crochetées ou tricotées à la main, nous recommandons en général un lavage à la main délicat à basse température, sans sèche-linge, et un séchage à plat pour préserver la forme et les fibres." },
|
||||
{ "q": "Quels moyens de paiement sont acceptés ?", "a": "PayPal, Klarna, carte bancaire (Visa & Mastercard) et virement bancaire (paiement anticipé)." },
|
||||
{ "q": "Quels moyens de paiement sont acceptés ?", "a": "PayPal (aussi en tant qu'invité par carte bancaire, sans compte PayPal) et virement bancaire (paiement anticipé)." },
|
||||
{ "q": "Puis-je retourner ma commande ?", "a": "Le droit de rétractation légal de 14 jours s'applique (voir notre notice de rétractation). Pour les pièces personnalisées sur mesure, le droit de rétractation peut être exclu par la loi ; pour les articles standards de notre gamme, il s'applique normalement." },
|
||||
{ "q": "Dois-je payer la TVA ?", "a": "Non. Van's DIY & Bastelbedarf est une petite entreprise selon l'§19 UStG – aucune TVA n'est calculée ni indiquée." },
|
||||
{ "q": "Ai-je besoin d'un compte client pour commander ?", "a": "Non, vous pouvez aussi commander confortablement en tant qu'invité. Un compte est toutefois utile pour regrouper l'historique des commandes, la liste de souhaits et le suivi de livraison." }
|
||||
|
||||
+24
-32
@@ -151,7 +151,7 @@ export const ui = {
|
||||
"Versandkostenfrei ab 75 € Warenwert (Deutschland), 85 € (Österreich, Luxemburg) bzw. 100 € (Schweiz)", "Der Versand erfolgt erst nach Zahlungseingang",
|
||||
"Bei Lieferungen in die Schweiz können Schweizer Einfuhrumsatzsteuer und Zollgebühren anfallen, die von dir als Empfänger:in getragen werden",
|
||||
],
|
||||
paymentTitle: "💳 Zahlungsarten", paymentItems: ["PayPal", "Klarna", "Kreditkarte (Visa & Mastercard)", "Banküberweisung (Vorkasse)"],
|
||||
paymentTitle: "💳 Zahlungsarten", paymentItems: ["PayPal", "Kredit-/Debitkarte als Gast über PayPal (Visa & Mastercard)", "Banküberweisung (Vorkasse)"],
|
||||
todoNote: "Konkrete Versandkosten-Staffel je Land/Warenwert wird ergänzt, sobald VanVan die finalen DHL-Konditionen festgelegt hat.",
|
||||
},
|
||||
cart: {
|
||||
@@ -177,7 +177,7 @@ export const ui = {
|
||||
},
|
||||
checkout: {
|
||||
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: "PayPal ist technisch fertig angebunden (echte, server-seitig geprüfte Zahlung) — bis ein echtes PayPal-Business-Konto hinterlegt ist, läuft es im kostenlosen PayPal-Testmodus, ohne dass echtes Geld fließt. Banküberweisung ist zu 100 % echt.",
|
||||
step1: "2. Kontakt & Lieferadresse", email: "E-Mail", firstName: "Vorname", lastName: "Nachname",
|
||||
street: "Straße & Hausnummer", zip: "PLZ", city: "Ort", country: "Land",
|
||||
step2: "1. Zahlungsart",
|
||||
@@ -185,12 +185,10 @@ export const ui = {
|
||||
revocationPrefix: "Ich habe die", revocationLink: "Widerrufsbelehrung", revocationAnd: "und",
|
||||
privacyLink: "Datenschutzerklärung", revocationSuffix: "zur Kenntnis genommen.",
|
||||
bankTransfer: "Banküberweisung (Vorkasse)", orderButton: "Jetzt zahlungspflichtig bestellen", emptyCartAlert: "Dein Warenkorb ist leer.",
|
||||
creditCard: "Kreditkarte", creditCardSub: "Visa & Mastercard",
|
||||
payPalHint: "Bezahle sicher und schnell direkt mit deinem PayPal-Konto.",
|
||||
klarnaHint: "Rechnung, Ratenkauf oder Sofortüberweisung – wähle bei Klarna, wie du zahlen möchtest.",
|
||||
klarnaConnect: "Weiter zu Klarna →", klarnaConnecting: "Verbinde mit Klarna …", klarnaConnected: "Verbindung zu Klarna hergestellt (Demo)",
|
||||
cardHint: "Deine Kartendaten werden sicher übertragen.", cardNumber: "Kartennummer", cardExpiry: "Gültig bis", cardCvc: "CVC",
|
||||
bankHintReal: "Bitte überweise den Rechnungsbetrag nach Bestellabschluss an folgendes Konto. Deine Ware wird nach Zahlungseingang verschickt.",
|
||||
payPalSub: "auch mit Kreditkarte, ohne eigenes Konto",
|
||||
payPalHint: "Bezahle sicher direkt mit deinem PayPal-Konto oder als Gast per Kredit-/Debitkarte – deine Kartendaten laufen dabei ausschließlich über PayPals eigene, gesicherte Seite, nie über unsere.",
|
||||
payPalCompleteHint: "Schließe deine Bestellung oben über den PayPal-Button ab – dort bezahlst du direkt, danach ist deine Bestellung sofort da.",
|
||||
bankHintReal: "Bitte überweise den Rechnungsbetrag nach Bestellabschluss an folgendes Konto. Deine Ware wird erst nach Zahlungseingang verschickt.",
|
||||
bankPlaceholder: "Die Bankverbindung wird dir nach Bestelleingang per E-Mail zugesendet.",
|
||||
bankHolder: "Kontoinhaber:in", bankIban: "IBAN", bankBic: "BIC", bankBankName: "Bank",
|
||||
shippingHintTitle: "Versandhinweis",
|
||||
@@ -455,7 +453,7 @@ export const ui = {
|
||||
"Free shipping from €75 order value (Germany), €85 (Austria, Luxembourg) or €100 (Switzerland)", "Shipping only starts after payment has been received",
|
||||
"Deliveries to Switzerland may be subject to Swiss import VAT and customs fees, payable by you as the recipient",
|
||||
],
|
||||
paymentTitle: "💳 Payment methods", paymentItems: ["PayPal", "Klarna", "Credit card (Visa & Mastercard)", "Bank transfer (prepayment)"],
|
||||
paymentTitle: "💳 Payment methods", paymentItems: ["PayPal", "Credit/debit card as a guest via PayPal (Visa & Mastercard)", "Bank transfer (prepayment)"],
|
||||
todoNote: "Concrete shipping-cost tiers per country/order value will be added once VanVan finalizes the DHL terms.",
|
||||
},
|
||||
cart: {
|
||||
@@ -481,7 +479,7 @@ export const ui = {
|
||||
},
|
||||
checkout: {
|
||||
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: "PayPal is technically fully connected (real, server-verified payment) — until a real PayPal Business account is set up, it runs in PayPal's free test mode, so no real money moves yet. Bank transfer is 100% real.",
|
||||
step1: "2. Contact & shipping address", email: "Email", firstName: "First name", lastName: "Last name",
|
||||
street: "Street & house number", zip: "ZIP code", city: "City", country: "Country",
|
||||
step2: "1. Payment method",
|
||||
@@ -489,12 +487,10 @@ export const ui = {
|
||||
revocationPrefix: "I have taken note of the", revocationLink: "withdrawal notice", revocationAnd: "and",
|
||||
privacyLink: "privacy policy", revocationSuffix: ".",
|
||||
bankTransfer: "Bank transfer (prepayment)", orderButton: "Order now, subject to payment", emptyCartAlert: "Your cart is empty.",
|
||||
creditCard: "Credit card", creditCardSub: "Visa & Mastercard",
|
||||
payPalHint: "Pay securely and instantly with your PayPal account.",
|
||||
klarnaHint: "Invoice, installments or instant transfer – choose how you'd like to pay with Klarna.",
|
||||
klarnaConnect: "Continue to Klarna →", klarnaConnecting: "Connecting to Klarna …", klarnaConnected: "Connected to Klarna (demo)",
|
||||
cardHint: "Your card details are transmitted securely.", cardNumber: "Card number", cardExpiry: "Expiry", cardCvc: "CVC",
|
||||
bankHintReal: "Please transfer the invoice amount to the following account after completing your order. Your items ship once payment is received.",
|
||||
payPalSub: "also with credit card, no account needed",
|
||||
payPalHint: "Pay securely with your PayPal account or as a guest by credit/debit card – your card details are handled entirely on PayPal's own secure page, never on ours.",
|
||||
payPalCompleteHint: "Complete your order via the PayPal button above – you pay there directly, and your order is placed right after.",
|
||||
bankHintReal: "Please transfer the invoice amount to the following account after completing your order. Your items ship only once payment is received.",
|
||||
bankPlaceholder: "Bank details will be sent to you by email after your order.",
|
||||
bankHolder: "Account holder", bankIban: "IBAN", bankBic: "BIC", bankBankName: "Bank",
|
||||
shippingHintTitle: "Shipping note",
|
||||
@@ -759,7 +755,7 @@ export const ui = {
|
||||
"Gratisversand ab 75 € Warewärt (Dütschland), 85 € (Öschterrych, Luxemburg) bzw. 100 € (Schwiz)", "Dr Versand geit erscht los, wenn d'Zahlig igange isch",
|
||||
"Bi Liefrige i d'Schwiz chöi schwyzerischi Iifuehrumsatzsteuer und Zollgebühre afalle, wo du als Empfänger:in trage muesch",
|
||||
],
|
||||
paymentTitle: "💳 Zahligsarte", paymentItems: ["PayPal", "Klarna", "Kreditkarte (Visa & Mastercard)", "Banküberwysig (Vorusszahlig)"],
|
||||
paymentTitle: "💳 Zahligsarte", paymentItems: ["PayPal", "Kredit-/Debitkarte als Gast über PayPal (Visa & Mastercard)", "Banküberwysig (Vorusszahlig)"],
|
||||
todoNote: "Di gnaui Versandchoschte-Staffle je Land/Warewärt wird ergänzt, sobald VanVan di definitive DHL-Konditione festgleit hät.",
|
||||
},
|
||||
cart: {
|
||||
@@ -785,7 +781,7 @@ export const ui = {
|
||||
},
|
||||
checkout: {
|
||||
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: "PayPal isch technisch fertig aabunde (echti, server-seitig gprüefti Zahlig) — bis es echts PayPal-Business-Konto hinterlegt isch, laufts im gratis PayPal-Testmodus, ohni dass echts Gäld flüsst. Banküberwysig isch zu 100 % echt.",
|
||||
step1: "2. Kontakt & Lieferadrässe", email: "E-Mail", firstName: "Vorname", lastName: "Nachname",
|
||||
street: "Strasse & Huusnummere", zip: "PLZ", city: "Ort", country: "Land",
|
||||
step2: "1. Zahligsart",
|
||||
@@ -793,12 +789,10 @@ export const ui = {
|
||||
revocationPrefix: "Ich ha d'", revocationLink: "Widerrufsbelehrig", revocationAnd: "und d'",
|
||||
privacyLink: "Datenschutzerklärig", revocationSuffix: "zur Kenntnis gnoh.",
|
||||
bankTransfer: "Banküberwysig (Vorusszahlig)", orderButton: "Jetzt zahligspflichtig bstelle", emptyCartAlert: "Din Warenchorb isch leer.",
|
||||
creditCard: "Kreditkarte", creditCardSub: "Visa & Mastercard",
|
||||
payPalHint: "Zahl sicher und schnäll dirräkt mit dim PayPal-Konto.",
|
||||
klarnaHint: "Rächnig, Ratechauf oder Sofortiübrwysig – wähl bi Klarna, wie du zahle wosch.",
|
||||
klarnaConnect: "Wyter zu Klarna →", klarnaConnecting: "Verbindet mit Klarna …", klarnaConnected: "Verbindig zu Klarna hergstellt (Demo)",
|
||||
cardHint: "Dini Kartedate werde sicher übertreit.", cardNumber: "Kartenummere", cardExpiry: "Gültig bis", cardCvc: "CVC",
|
||||
bankHintReal: "Bitte überwys dr Rächnigsbetrag nach Bstellabschluss uf das Konto. Dini Ware wird nach Zahligsigang verschickt.",
|
||||
payPalSub: "au mit Kreditkarte, ohni eigenes Konto",
|
||||
payPalHint: "Zahl sicher dirräkt mit dim PayPal-Konto oder als Gast mit Kredit-/Debitkarte – dini Kartedate laufe dabi nur über PayPals eigeni, gsicherti Site, nie über üsi.",
|
||||
payPalCompleteHint: "Schliess dini Bstellig obe über de PayPal-Knopf ab – dört zahlsch dirräkt, danach isch dini Bstellig sofort da.",
|
||||
bankHintReal: "Bitte überwys dr Rächnigsbetrag nach Bstellabschluss uf das Konto. Dini Ware wird erst nach Zahligsigang verschickt.",
|
||||
bankPlaceholder: "D'Bankverbindig wird dir nach Bstelligang per E-Mail zuegschickt.",
|
||||
bankHolder: "Kontoinhaber:in", bankIban: "IBAN", bankBic: "BIC", bankBankName: "Bank",
|
||||
shippingHintTitle: "Versandhinwys",
|
||||
@@ -1063,7 +1057,7 @@ export const ui = {
|
||||
"Livraison gratuite dès 75 € d'achat (Allemagne), 85 € (Autriche, Luxembourg) ou 100 € (Suisse)", "L'expédition n'a lieu qu'après réception du paiement",
|
||||
"Pour les livraisons en Suisse, la TVA à l'importation suisse et des frais de douane peuvent s'appliquer, à la charge du/de la destinataire",
|
||||
],
|
||||
paymentTitle: "💳 Moyens de paiement", paymentItems: ["PayPal", "Klarna", "Carte bancaire (Visa & Mastercard)", "Virement bancaire (paiement anticipé)"],
|
||||
paymentTitle: "💳 Moyens de paiement", paymentItems: ["PayPal", "Carte bancaire en tant qu'invité via PayPal (Visa & Mastercard)", "Virement bancaire (paiement anticipé)"],
|
||||
todoNote: "Le barème précis des frais de port par pays/valeur de commande sera ajouté dès que VanVan aura fixé les conditions DHL définitives.",
|
||||
},
|
||||
cart: {
|
||||
@@ -1089,7 +1083,7 @@ export const ui = {
|
||||
},
|
||||
checkout: {
|
||||
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: "PayPal est techniquement entièrement connecté (paiement réel, vérifié côté serveur) — tant qu'aucun compte PayPal Business réel n'est configuré, il fonctionne en mode test gratuit de PayPal, sans transfert d'argent réel. Le virement bancaire est 100 % réel.",
|
||||
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",
|
||||
step2: "1. Moyen de paiement",
|
||||
@@ -1097,12 +1091,10 @@ export const ui = {
|
||||
revocationPrefix: "J'ai pris connaissance de la", revocationLink: "notice de rétractation", revocationAnd: "et de la",
|
||||
privacyLink: "politique de confidentialité", revocationSuffix: ".",
|
||||
bankTransfer: "Virement bancaire (paiement anticipé)", orderButton: "Commander avec obligation de paiement", emptyCartAlert: "Votre panier est vide.",
|
||||
creditCard: "Carte bancaire", creditCardSub: "Visa & Mastercard",
|
||||
payPalHint: "Payez en toute sécurité et instantanément avec votre compte PayPal.",
|
||||
klarnaHint: "Facture, paiement en plusieurs fois ou virement instantané – choisissez comment payer avec Klarna.",
|
||||
klarnaConnect: "Continuer vers Klarna →", klarnaConnecting: "Connexion à Klarna …", klarnaConnected: "Connecté à Klarna (démo)",
|
||||
cardHint: "Vos données de carte sont transmises en toute sécurité.", cardNumber: "Numéro de carte", cardExpiry: "Date d'expiration", cardCvc: "CVC",
|
||||
bankHintReal: "Veuillez virer le montant de la facture sur le compte suivant après avoir finalisé votre commande. Vos articles sont expédiés dès réception du paiement.",
|
||||
payPalSub: "aussi par carte bancaire, sans compte",
|
||||
payPalHint: "Payez en toute sécurité avec votre compte PayPal ou en tant qu'invité par carte bancaire – vos données de carte transitent uniquement par le site sécurisé de PayPal, jamais par le nôtre.",
|
||||
payPalCompleteHint: "Finalisez votre commande via le bouton PayPal ci-dessus – vous y payez directement, votre commande est passée juste après.",
|
||||
bankHintReal: "Veuillez virer le montant de la facture sur le compte suivant après avoir finalisé votre commande. Vos articles ne sont expédiés qu'après réception du paiement.",
|
||||
bankPlaceholder: "Les coordonnées bancaires vous seront envoyées par e-mail après votre commande.",
|
||||
bankHolder: "Titulaire du compte", bankIban: "IBAN", bankBic: "BIC", bankBankName: "Banque",
|
||||
shippingHintTitle: "Remarque sur la livraison",
|
||||
|
||||
+107
-126
@@ -6,7 +6,7 @@ import { useTranslations } from "../../../i18n/ui";
|
||||
const lang: Locale = "ch";
|
||||
const t = useTranslations(lang);
|
||||
---
|
||||
<Layout title="Checkout" description="Bschtellig abschliesse bi Van's DIY & Bastelbedarf." lang={lang} path="/checkout/">
|
||||
<Layout title="Checkout" description="Bstellig abschliesse bi Van's DIY & Bastelbedarf." lang={lang} path="/checkout/">
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<span class="eyebrow">{t.checkout.eyebrow}</span>
|
||||
@@ -20,65 +20,22 @@ 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: Klarna raus (Bonitätsprüfungs-Pflichten), keine eigene
|
||||
Kreditkarten-Eingabemaske (PCI-DSS-pflichtig) — Kartenzahlung läuft stattdessen über
|
||||
PayPals eigenen, PCI-zertifizierten Gast-Checkout. */}
|
||||
<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 +94,8 @@ const t = useTranslations(lang);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-block" type="submit">{t.checkout.orderButton}</button>
|
||||
<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,9 +131,9 @@ 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: "/ch/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: "/ch/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 laufen als IIFE, keine "import"-Anweisungen möglich.
|
||||
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";
|
||||
@@ -185,12 +143,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" isch PayPals gratis Sandbox-Test-ID — funktioniert sofort ohni
|
||||
// eigenes Konto, aber es flüsst nie echts Gäld. Für echte Zahligsigang hie die eigeti PayPal-
|
||||
// Business-Client-ID iitrage (gratis uf developer.paypal.com).
|
||||
const PAYPAL_CLIENT_ID = "sb";
|
||||
|
||||
const summary = document.getElementById("checkout-summary");
|
||||
const landSelect = document.getElementById("land");
|
||||
// Nur die im Warenkorb ausgewählten Artikel werden hier bestellt — abgewählte bleiben im
|
||||
// Warenkorb liegen und tauchen im Checkout gar nicht erst auf.
|
||||
const cart = getCart().filter(istAusgewaehlt);
|
||||
const subtotal = cartTotal();
|
||||
let currentTotal = 0;
|
||||
@@ -250,59 +211,103 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
renderSummary();
|
||||
landSelect.addEventListener("change", renderSummary);
|
||||
|
||||
// Zahlungsart-Auswahl: der "Verbinden"-Bereich jeder Karte klappt rein über CSS
|
||||
// (:has(input:checked)) sofort auf, sobald man die Karte anklickt — kein JS, keine Verzögerung.
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function nachBestellErfolg() {
|
||||
if (treuebonusDiscount() > 0) {
|
||||
treuebonusEinloesen();
|
||||
} else {
|
||||
registriereAbgeschlosseneBestellung();
|
||||
}
|
||||
location.href = thankYouPath;
|
||||
}
|
||||
|
||||
// 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.
|
||||
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¤cy=EUR&intent=capture&disable-funding=card,credit";
|
||||
script.src = `https://www.paypal.com/sdk/js?client-id=${PAYPAL_CLIENT_ID}¤cy=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.
|
||||
const bankDetailsEl = document.getElementById("bank-details");
|
||||
if (bankDetailsEl) {
|
||||
if (bankverbindungVollstaendig(bankverbindung)) {
|
||||
@@ -336,7 +341,7 @@ 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)";
|
||||
@@ -350,53 +355,29 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
return;
|
||||
}
|
||||
|
||||
const zahlungsart = document.querySelector('input[name="pay"]:checked')?.value || "";
|
||||
if (zahlungsart === "paypal") {
|
||||
orderErrorEl.textContent = "⚠️ " + payPalCompleteHintLabel;
|
||||
orderErrorEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
+144
-131
@@ -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¤cy=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}¤cy=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";
|
||||
|
||||
+110
-125
@@ -20,65 +20,22 @@ 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: Klarna raus (Bonitätsprüfungs-Pflichten), keine eigene
|
||||
Kreditkarten-Eingabemaske (PCI-DSS-pflichtig) — Kartenzahlung läuft stattdessen über
|
||||
PayPals eigenen, PCI-zertifizierten Gast-Checkout. */}
|
||||
<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 +94,8 @@ const t = useTranslations(lang);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-block" type="submit">{t.checkout.orderButton}</button>
|
||||
<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,9 +131,9 @@ 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: "/en/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: "/en/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 laufen als IIFE, keine "import"-Anweisungen möglich.
|
||||
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";
|
||||
@@ -185,12 +143,16 @@ 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" is PayPal's free sandbox test ID — works immediately without an
|
||||
// account, but no real money moves. Replace with VanVan's own (free) PayPal Business client ID
|
||||
// once ready (developer.paypal.com). The client ID itself is NOT secret (unlike the matching
|
||||
// secret, which stays server-side only, see functions/_shared/paypal.js).
|
||||
const PAYPAL_CLIENT_ID = "sb";
|
||||
|
||||
const summary = document.getElementById("checkout-summary");
|
||||
const landSelect = document.getElementById("land");
|
||||
// Nur die im Warenkorb ausgewählten Artikel werden hier bestellt — abgewählte bleiben im
|
||||
// Warenkorb liegen und tauchen im Checkout gar nicht erst auf.
|
||||
const cart = getCart().filter(istAusgewaehlt);
|
||||
const subtotal = cartTotal();
|
||||
let currentTotal = 0;
|
||||
@@ -250,59 +212,106 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
renderSummary();
|
||||
landSelect.addEventListener("change", renderSummary);
|
||||
|
||||
// Zahlungsart-Auswahl: der "Verbinden"-Bereich jeder Karte klappt rein über CSS
|
||||
// (:has(input:checked)) sofort auf, sobald man die Karte anklickt — kein JS, keine Verzögerung.
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// 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.
|
||||
function nachBestellErfolg() {
|
||||
if (treuebonusDiscount() > 0) {
|
||||
treuebonusEinloesen();
|
||||
} else {
|
||||
registriereAbgeschlosseneBestellung();
|
||||
}
|
||||
location.href = thankYouPath;
|
||||
}
|
||||
|
||||
// PayPal: real, server-verified payment flow (see functions/api/paypal/ +
|
||||
// functions/_shared/paypal.js) — createOrder/onApprove both call our own server, which talks
|
||||
// to PayPal directly, instead of trusting the browser's own capture() result.
|
||||
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¤cy=EUR&intent=capture&disable-funding=card,credit";
|
||||
script.src = `https://www.paypal.com/sdk/js?client-id=${PAYPAL_CLIENT_ID}¤cy=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("Cart empty");
|
||||
}
|
||||
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 || "Could not create PayPal order.");
|
||||
throw new Error(data?.error || "PayPal order failed.");
|
||||
}
|
||||
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 || "Payment could not be completed.");
|
||||
return;
|
||||
}
|
||||
nachBestellErfolg();
|
||||
} catch (err) {
|
||||
zeigePaypalFehler(err instanceof Error ? err.message : "Unknown payment error.");
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
zeigePaypalFehler("PayPal reported an error. Please try again or choose bank transfer.");
|
||||
},
|
||||
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.
|
||||
const bankDetailsEl = document.getElementById("bank-details");
|
||||
if (bankDetailsEl) {
|
||||
if (bankverbindungVollstaendig(bankverbindung)) {
|
||||
@@ -336,7 +345,7 @@ 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)";
|
||||
@@ -350,53 +359,29 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
return;
|
||||
}
|
||||
|
||||
const zahlungsart = document.querySelector('input[name="pay"]:checked')?.value || "";
|
||||
if (zahlungsart === "paypal") {
|
||||
orderErrorEl.textContent = "⚠️ " + payPalCompleteHintLabel;
|
||||
orderErrorEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
+106
-125
@@ -20,65 +20,22 @@ 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: Klarna raus (Bonitätsprüfungs-Pflichten), keine eigene
|
||||
Kreditkarten-Eingabemaske (PCI-DSS-pflichtig) — Kartenzahlung läuft stattdessen über
|
||||
PayPals eigenen, PCI-zertifizierten Gast-Checkout. */}
|
||||
<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 +94,8 @@ const t = useTranslations(lang);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-block" type="submit">{t.checkout.orderButton}</button>
|
||||
<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,9 +131,9 @@ 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: "/fr/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: "/fr/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 laufen als IIFE, keine "import"-Anweisungen möglich.
|
||||
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";
|
||||
@@ -185,12 +143,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" est l'identifiant sandbox gratuit de PayPal — fonctionne
|
||||
// immédiatement sans compte, mais aucun argent réel ne circule. Remplacer par le vrai
|
||||
// identifiant client PayPal Business de VanVan une fois prêt (developer.paypal.com).
|
||||
const PAYPAL_CLIENT_ID = "sb";
|
||||
|
||||
const summary = document.getElementById("checkout-summary");
|
||||
const landSelect = document.getElementById("land");
|
||||
// Nur die im Warenkorb ausgewählten Artikel werden hier bestellt — abgewählte bleiben im
|
||||
// Warenkorb liegen und tauchen im Checkout gar nicht erst auf.
|
||||
const cart = getCart().filter(istAusgewaehlt);
|
||||
const subtotal = cartTotal();
|
||||
let currentTotal = 0;
|
||||
@@ -250,59 +211,103 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
renderSummary();
|
||||
landSelect.addEventListener("change", renderSummary);
|
||||
|
||||
// Zahlungsart-Auswahl: der "Verbinden"-Bereich jeder Karte klappt rein über CSS
|
||||
// (:has(input:checked)) sofort auf, sobald man die Karte anklickt — kein JS, keine Verzögerung.
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function nachBestellErfolg() {
|
||||
if (treuebonusDiscount() > 0) {
|
||||
treuebonusEinloesen();
|
||||
} else {
|
||||
registriereAbgeschlosseneBestellung();
|
||||
}
|
||||
location.href = thankYouPath;
|
||||
}
|
||||
|
||||
// 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.
|
||||
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¤cy=EUR&intent=capture&disable-funding=card,credit";
|
||||
script.src = `https://www.paypal.com/sdk/js?client-id=${PAYPAL_CLIENT_ID}¤cy=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("Panier vide");
|
||||
}
|
||||
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 || "Impossible de créer la commande PayPal.");
|
||||
throw new Error(data?.error || "Échec de la commande PayPal.");
|
||||
}
|
||||
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 || "Le paiement n'a pas pu être finalisé.");
|
||||
return;
|
||||
}
|
||||
nachBestellErfolg();
|
||||
} catch (err) {
|
||||
zeigePaypalFehler(err instanceof Error ? err.message : "Erreur de paiement inconnue.");
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
zeigePaypalFehler("PayPal a signalé une erreur. Réessayez ou choisissez le virement bancaire.");
|
||||
},
|
||||
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.
|
||||
const bankDetailsEl = document.getElementById("bank-details");
|
||||
if (bankDetailsEl) {
|
||||
if (bankverbindungVollstaendig(bankverbindung)) {
|
||||
@@ -336,7 +341,7 @@ 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)";
|
||||
@@ -350,53 +355,29 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
return;
|
||||
}
|
||||
|
||||
const zahlungsart = document.querySelector('input[name="pay"]:checked')?.value || "";
|
||||
if (zahlungsart === "paypal") {
|
||||
orderErrorEl.textContent = "⚠️ " + payPalCompleteHintLabel;
|
||||
orderErrorEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
@@ -74,6 +74,14 @@ const lang: Locale = "de";
|
||||
</div>
|
||||
<div class="verwaltung-box-body">
|
||||
<div class="verwaltung-stat-grid">
|
||||
{/* Neu seit der echten PayPal-Anbindung: Überweisungs-Bestellungen starten mit
|
||||
Status "zahlungOffen" (Geld noch nicht da) statt fälschlich sofort "bezahlt" zu
|
||||
heißen — eigene Kachel, damit sofort auffällt, worauf noch gewartet wird. */}
|
||||
<button type="button" class="card verwaltung-stat-tile" data-filter="zahlungOffen">
|
||||
<span class="verwaltung-stat-icon" aria-hidden="true">⏳</span>
|
||||
<span class="verwaltung-stat-value" id="stat-zahlungOffen">0</span>
|
||||
<span class="verwaltung-stat-label">Zahlung ausstehend</span>
|
||||
</button>
|
||||
<button type="button" class="card verwaltung-stat-tile" data-filter="offen">
|
||||
<span class="verwaltung-stat-icon" aria-hidden="true">🆕</span>
|
||||
<span class="verwaltung-stat-value" id="stat-offen">0</span>
|
||||
@@ -259,6 +267,7 @@ const lang: Locale = "de";
|
||||
|
||||
<nav class="verwaltung-filter" aria-label="Nach Status filtern">
|
||||
<button type="button" class="verwaltung-filter-btn is-active" data-filter="alle">Alle (<span id="filter-count-alle">0</span>)</button>
|
||||
<button type="button" class="verwaltung-filter-btn" data-filter="zahlungOffen">Zahlung ausstehend (<span id="filter-count-zahlungOffen">0</span>)</button>
|
||||
<button type="button" class="verwaltung-filter-btn" data-filter="offen">Offen (<span id="filter-count-offen">0</span>)</button>
|
||||
<button type="button" class="verwaltung-filter-btn" data-filter="inBearbeitung">In Bearbeitung (<span id="filter-count-inBearbeitung">0</span>)</button>
|
||||
<button type="button" class="verwaltung-filter-btn" data-filter="versendet">Versendet (<span id="filter-count-versendet">0</span>)</button>
|
||||
@@ -288,6 +297,7 @@ const lang: Locale = "de";
|
||||
|
||||
const lang = "de";
|
||||
const STATUS_LABEL = {
|
||||
zahlungOffen: "Zahlung ausstehend",
|
||||
bezahlt: "Bezahlt",
|
||||
bearbeitung: "In Bearbeitung",
|
||||
versandVorbereitet: "Versand wird vorbereitet",
|
||||
@@ -297,7 +307,11 @@ const lang: Locale = "de";
|
||||
};
|
||||
const LAND_NAME = { de: "Deutschland", at: "Österreich", ch: "Schweiz", lu: "Luxemburg" };
|
||||
const LAND_FLAGGE = { de: "🇩🇪", at: "🇦🇹", ch: "🇨🇭", lu: "🇱🇺" };
|
||||
const ZAHLUNGSART_LABEL = { paypal: "💙 PayPal", klarna: "🩷 Klarna", kreditkarte: "💳 Kreditkarte", ueberweisung: "🏦 Überweisung" };
|
||||
// Nur noch PayPal (echt, server-verifiziert) und Überweisung (Vorkasse) — Klarna und die
|
||||
// frühere eigene Kreditkarten-Eingabemaske wurden auf ausdrücklichen Wunsch entfernt (siehe
|
||||
// Vault-Doku: Klarna bräuchte eine eigene Händlerprüfung + Bonitätsprüfungs-Pflichten, eine
|
||||
// eigene Kartenmaske wäre PCI-DSS-pflichtig gewesen).
|
||||
const ZAHLUNGSART_LABEL = { paypal: "💙 PayPal", ueberweisung: "🏦 Überweisung" };
|
||||
const INVOICE_LABELS = {
|
||||
title: "Rechnung", numberLabel: "Rechnungsnummer", dateLabel: "Rechnungsdatum",
|
||||
sellerLabel: "Verkäufer", billToLabel: "Rechnungsempfänger",
|
||||
@@ -311,6 +325,7 @@ const lang: Locale = "de";
|
||||
|
||||
function bucket(status) {
|
||||
if (status === "storniert") return "storniert";
|
||||
if (status === "zahlungOffen") return "zahlungOffen";
|
||||
if (status === "bezahlt") return "offen";
|
||||
if (status === "bearbeitung" || status === "versandVorbereitet") return "inBearbeitung";
|
||||
if (status === "versendet") return "versendet";
|
||||
@@ -396,7 +411,9 @@ const lang: Locale = "de";
|
||||
}
|
||||
|
||||
function render() {
|
||||
const aktive = alleBestellungen.filter((b) => b.status !== "storniert");
|
||||
// "aktive" = zählt für Umsatz/Auswertung: weder storniert noch (bei Überweisung) noch gar
|
||||
// nicht bezahlt — sonst würde Geld gezeigt, das VanVan noch gar nicht hat.
|
||||
const aktive = alleBestellungen.filter((b) => b.status !== "storniert" && b.status !== "zahlungOffen");
|
||||
letzteAktive = aktive;
|
||||
|
||||
// Die Auswertungs-Detail-Panels zeigen ggf. veraltete Daten, sobald sich die Bestellliste
|
||||
@@ -408,20 +425,24 @@ const lang: Locale = "de";
|
||||
document.querySelectorAll(".verwaltung-stat-tile.is-active").forEach((t) => t.classList.remove("is-active"));
|
||||
|
||||
// ── Buckets für die Stat-Kacheln + Filterleiste ──
|
||||
const buckets = { offen: 0, inBearbeitung: 0, versendet: 0, abgeschlossen: 0, storniert: 0 };
|
||||
const buckets = { zahlungOffen: 0, offen: 0, inBearbeitung: 0, versendet: 0, abgeschlossen: 0, storniert: 0 };
|
||||
alleBestellungen.forEach((b) => { buckets[bucket(b.status)]++; });
|
||||
setText("stat-zahlungOffen", String(buckets.zahlungOffen));
|
||||
setText("stat-offen", String(buckets.offen));
|
||||
setText("stat-inBearbeitung", String(buckets.inBearbeitung));
|
||||
setText("stat-versendet", String(buckets.versendet));
|
||||
setText("stat-abgeschlossen", String(buckets.abgeschlossen));
|
||||
setText("filter-count-alle", String(alleBestellungen.length));
|
||||
setText("filter-count-zahlungOffen", String(buckets.zahlungOffen));
|
||||
setText("filter-count-offen", String(buckets.offen));
|
||||
setText("filter-count-inBearbeitung", String(buckets.inBearbeitung));
|
||||
setText("filter-count-versendet", String(buckets.versendet));
|
||||
setText("filter-count-abgeschlossen", String(buckets.abgeschlossen));
|
||||
setText("filter-count-storniert", String(buckets.storniert));
|
||||
|
||||
// ── Umsatz + Ø Bestellwert: stornierte Bestellungen zählen bewusst NICHT mit. ──
|
||||
// ── Umsatz + Ø Bestellwert: stornierte UND noch nicht bezahlte (zahlungOffen) Bestellungen
|
||||
// zählen bewusst NICHT mit — sonst würde der Umsatz Geld zeigen, das noch gar nicht da
|
||||
// ist. ──
|
||||
const gesamtumsatz = aktive.reduce((s, b) => s + Number(b.summe), 0);
|
||||
setText("stat-umsatz", formatPrice(gesamtumsatz, lang));
|
||||
setText("stat-umsatz-label", `Gesamtumsatz (${aktive.length} Bestellung${aktive.length === 1 ? "" : "en"})`);
|
||||
@@ -446,8 +467,13 @@ const lang: Locale = "de";
|
||||
`Diesen Monat${monatCount > 0 ? ` (${formatPrice(monatUmsatz, lang)})` : ""}`
|
||||
);
|
||||
|
||||
// Bewusst über ALLE Bestellungen (nicht nur "aktive"): eine seit Tagen unbezahlte
|
||||
// Überweisungs-Bestellung ("zahlungOffen") braucht mindestens genauso dringend Aufmerksamkeit
|
||||
// wie eine liegen gebliebene bezahlte Bestellung — sonst würde sie einfach vergessen.
|
||||
const attentionIds = new Set(
|
||||
aktive.filter((b) => b.status === "bezahlt" && jetzt - new Date(b.created_at).getTime() > ALTER_SCHWELLE_TAGE * MS_PRO_TAG).map((b) => b.id)
|
||||
alleBestellungen
|
||||
.filter((b) => (b.status === "bezahlt" || b.status === "zahlungOffen") && jetzt - new Date(b.created_at).getTime() > ALTER_SCHWELLE_TAGE * MS_PRO_TAG)
|
||||
.map((b) => b.id)
|
||||
);
|
||||
const attentionTile = document.getElementById("stat-attention-tile");
|
||||
if (attentionIds.size > 0) {
|
||||
|
||||
+30
-4
@@ -1709,6 +1709,7 @@ a:focus-visible, button:focus-visible {
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .order-card, .order-card::after { animation: none; } }
|
||||
|
||||
.order-card-zahlungOffen { --status-color: #e08a3c; }
|
||||
.order-card-bezahlt { --status-color: #9b7fd6; }
|
||||
.order-card-bearbeitung { --status-color: #e3b23c; }
|
||||
.order-card-versandVorbereitet { --status-color: #e3b23c; }
|
||||
@@ -1718,6 +1719,7 @@ a:focus-visible, button:focus-visible {
|
||||
|
||||
.order-status { display: inline-flex; align-items: center; gap: 0.4em; }
|
||||
.order-status::before { font-size: 0.95em; }
|
||||
.order-status-zahlungOffen::before { content: "⏳"; }
|
||||
.order-status-bezahlt::before { content: "💳"; }
|
||||
.order-status-bearbeitung::before { content: "⚙️"; }
|
||||
.order-status-versandVorbereitet::before { content: "📦"; }
|
||||
@@ -1725,6 +1727,7 @@ a:focus-visible, button:focus-visible {
|
||||
.order-status-abgeschlossen::before { content: "✅"; }
|
||||
.order-status-storniert::before { content: "❌"; }
|
||||
|
||||
.order-status-zahlungOffen { background: color-mix(in srgb, #e08a3c 30%, transparent); border-color: color-mix(in srgb, #e08a3c 55%, transparent); color: #ffd9ae; }
|
||||
.order-status-bezahlt { background: color-mix(in srgb, #9b7fd6 32%, transparent); border-color: color-mix(in srgb, #9b7fd6 55%, transparent); color: #d9cdf5; }
|
||||
.order-status-bearbeitung { background: color-mix(in srgb, #e3b23c 30%, transparent); border-color: color-mix(in srgb, #e3b23c 55%, transparent); color: #f6dda0; }
|
||||
.order-status-versandVorbereitet { background: color-mix(in srgb, #e3b23c 30%, transparent); border-color: color-mix(in srgb, #e3b23c 55%, transparent); color: #f6dda0; }
|
||||
@@ -2368,10 +2371,33 @@ a:focus-visible, button:focus-visible {
|
||||
.pay-connect-btn[disabled] { opacity: 0.75; cursor: default; }
|
||||
.pay-connect-status { margin: 0.7em 0 0; font-size: 0.88rem; color: var(--c-accent); font-weight: 600; }
|
||||
|
||||
/* Inline-Kartenformular (Kreditkarte) */
|
||||
.card-fields { display: grid; gap: 0.7rem; }
|
||||
.card-fields label { display: block; font-size: 0.8rem; color: var(--c-text-muted); margin-bottom: 0.3rem; }
|
||||
.card-fields input { font-variant-numeric: tabular-nums; letter-spacing: 0.03em; }
|
||||
/* Kleine Visa/Mastercard-Hinweis-Badges direkt neben "PayPal" — zeigt, dass Kartenzahlung über
|
||||
den PayPal-Gast-Checkout ebenfalls möglich ist (Kartendaten laufen dabei komplett über PayPals
|
||||
eigene, PCI-DSS-zertifizierte Oberfläche, nie über unsere Seite — siehe Recherche zur
|
||||
Kreditkarten-Rechtslage: eine eigene Karten-Eingabemaske wäre PCI-DSS-pflichtig und für einen
|
||||
kleinen Shop praktisch nicht stemmbar). */
|
||||
.pay-sub-cards { display: inline-flex; align-items: center; gap: 0.3em; margin-left: 0.5em; vertical-align: middle; }
|
||||
.pay-sub-cards .mini-card { width: 26px; height: 18px; border-radius: 4px; }
|
||||
.pay-sub-cards .mini-card svg { width: 16px; height: auto; }
|
||||
|
||||
/* Der große "Jetzt zahlungspflichtig bestellen"-Knopf gehört NUR zur Überweisung (Vorkasse) —
|
||||
bei PayPal läuft die Zahlung ausschließlich über die echten PayPal-Smart-Buttons oben (siehe
|
||||
#paypal-button-container), die selbst die Bestellung auslösen, sobald die Zahlung bei PayPal
|
||||
bestätigt ist. Der Knopf würde bei ausgewähltem PayPal sonst fälschlich eine "Bestellung ohne
|
||||
Zahlung" ermöglichen — deshalb hier komplett ausgeblendet, kein Fallback, keine Ausnahme. */
|
||||
#checkout-form:has(input[name="pay"][value="paypal"]:checked) #order-submit-btn { display: none; }
|
||||
#checkout-form:has(input[name="pay"][value="paypal"]:checked) #order-submit-hint { display: block; }
|
||||
#order-submit-hint {
|
||||
display: none;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.9em 1.1em;
|
||||
border-radius: var(--radius-m);
|
||||
border: 1px solid color-mix(in srgb, var(--c-accent) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--c-accent) 8%, transparent);
|
||||
font-size: 0.88rem;
|
||||
color: var(--c-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Bankverbindung (Vorkasse) — echte, direkt aus dem Adminbereich gespeiste Kontodaten. */
|
||||
.bank-details {
|
||||
|
||||
Reference in New Issue
Block a user