Echtes Bestellsystem: Cloudflare D1 statt Beispieldaten in der Verwaltung
Ersetzt die Phase-1-Beispieldaten in /verwaltung/ durch ein echtes, funktionierendes Bestellsystem: - Neue D1-Datenbank (vans-diy-bastelbedarf-db) mit orders/order_items-Tabellen, an das Pages-Projekt gebunden (wrangler.toml). - Neue API-Routen (Pages Functions): POST /api/orders legt beim Checkout eine echte Bestellung an (mit Validierung); GET/PATCH /api/verwaltung/orders(/:id) listen bzw. ändern Bestellungen — beide zusätzlich zur Middleware nochmal eigenständig gegen die Zugangscode-Sitzung geprüft (Verteidigung in der Tiefe). Gemeinsame Auth-Logik aus _middleware.js nach functions/_shared/auth.js ausgelagert. - Checkout (4 Sprachen) sendet beim Absenden die echte Bestellung an die API, zeigt bei einem Fehler eine klare Meldung statt einfach auf die Danke-Seite weiterzuleiten. - /verwaltung/ lädt Bestellungen jetzt live per fetch(), mit Lade-/Fehlerzustand, und erlaubt direktes Ändern des Bestellstatus per Dropdown (sofort in der Datenbank gespeichert). Neuer Status "storniert" (zählt nicht in Umsatz/Auswertung mit). Vollständig lokal end-to-end getestet (wrangler pages dev + lokale D1): echter Browser-Checkout -> Bestellung landet in der Datenbank -> erscheint sofort in der Verwaltung -> Status ändern bleibt nach Neuladen bestehen. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
9170b9bb22
commit
fb7a7b76c2
@@ -0,0 +1,52 @@
|
||||
-- Van's DIY & Bastelbedarf — echtes Bestellsystem (Phase 2).
|
||||
--
|
||||
-- Ersetzt die Beispieldaten in /verwaltung/. Zwei Tabellen: eine Kopf-Tabelle pro Bestellung
|
||||
-- (orders) + eine Zeilen-Tabelle pro bestelltem Artikel (order_items) — bewusst normalisiert
|
||||
-- statt alles als ein JSON-Blob zu speichern, damit "meistverkaufte Produkte je Kategorie"
|
||||
-- eine echte, schnelle SQL-Auswertung ist (GROUP BY) statt jedes Mal alle Bestellungen im
|
||||
-- Code durchzuparsen.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
-- Interne, garantiert eindeutige numerische ID (SQLite AUTOINCREMENT) — die menschenlesbare
|
||||
-- Bestellnummer (z.B. "VDB-2026-0201") wird direkt nach dem Einfügen aus dieser ID abgeleitet
|
||||
-- (siehe functions/api/orders.js), dadurch nie eine Race Condition bei paralleler Bestellung.
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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')),
|
||||
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,
|
||||
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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
produkt_slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
kategorie_slug TEXT,
|
||||
menge INTEGER NOT NULL,
|
||||
preis REAL NOT NULL,
|
||||
gratis INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id);
|
||||
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);
|
||||
+20
-64
@@ -25,75 +25,22 @@
|
||||
Adminbereich hat sein eigenes, echtes Login (GitHub-OAuth über den
|
||||
cms-auth-Worker) und funktioniert für VanVan bereits einwandfrei; eine
|
||||
zusätzliche Schranke davor würde nur doppelt verwirren.
|
||||
- /verwaltung/* (interne Bestellübersicht) bleibt IMMER geschützt — auch
|
||||
wenn der Rest der Seite später per SITE_PUBLIC=true freigeschaltet wird
|
||||
(siehe unten). Diese Seite ist nie für Kund:innen gedacht.
|
||||
- /verwaltung/* (interne Bestellübersicht) und /api/verwaltung/* (die dazugehörigen
|
||||
Bestell-Daten, siehe functions/api/verwaltung/) bleiben IMMER geschützt — auch wenn der
|
||||
Rest der Seite später per SITE_PUBLIC=true freigeschaltet wird (siehe unten). Diese Seite
|
||||
ist nie für Kund:innen gedacht. /api/orders (Bestellung ANLEGEN beim Checkout) ist bewusst
|
||||
NICHT dauerhaft geschützt — das muss auch für echte Kund:innen erreichbar sein, sobald der
|
||||
Shop öffentlich ist, genau wie der Checkout selbst.
|
||||
- API-Routen (/api/*) bekommen bei fehlender Sitzung eine 401-JSON-Antwort statt der
|
||||
302-Weiterleitung zur Zugangscode-Seite — ein fetch() im Checkout/Verwaltung-Skript soll
|
||||
einen klaren Fehler sehen, nicht versehentlich HTML als Antwort bekommen.
|
||||
|
||||
Die Signatur (HMAC-SHA256 über Web Crypto, in jeder Workers/Pages-Runtime
|
||||
eingebaut) verhindert, dass jemand sich einfach selbst ein
|
||||
"vandiy_gate_session=irgendwas"-Cookie setzt.
|
||||
===================================================================== */
|
||||
|
||||
const COOKIE_NAME = "vandiy_gate_session";
|
||||
const ROLE_COOKIE_NAME = "vandiy_gate_role";
|
||||
const SESSION_TAGE = 90;
|
||||
|
||||
function b64urlEncode(bytes) {
|
||||
let bin = "";
|
||||
bytes.forEach((b) => (bin += String.fromCharCode(b)));
|
||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
function b64urlDecodeToBytes(str) {
|
||||
str = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (str.length % 4) str += "=";
|
||||
const bin = atob(str);
|
||||
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function hmacKey(secret) {
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"]
|
||||
);
|
||||
}
|
||||
|
||||
async function signSession(role, secret) {
|
||||
const payload = JSON.stringify({ role, exp: Date.now() + SESSION_TAGE * 24 * 60 * 60 * 1000 });
|
||||
const payloadB64 = b64urlEncode(new TextEncoder().encode(payload));
|
||||
const key = await hmacKey(secret);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payloadB64));
|
||||
const sigB64 = b64urlEncode(new Uint8Array(sig));
|
||||
return `${payloadB64}.${sigB64}`;
|
||||
}
|
||||
|
||||
async function verifySession(token, secret) {
|
||||
if (!token || !token.includes(".")) return null;
|
||||
const [payloadB64, sigB64] = token.split(".");
|
||||
try {
|
||||
const key = await hmacKey(secret);
|
||||
const valid = await crypto.subtle.verify(
|
||||
"HMAC",
|
||||
key,
|
||||
b64urlDecodeToBytes(sigB64),
|
||||
new TextEncoder().encode(payloadB64)
|
||||
);
|
||||
if (!valid) return null;
|
||||
const payload = JSON.parse(new TextDecoder().decode(b64urlDecodeToBytes(payloadB64)));
|
||||
if (!payload.exp || payload.exp < Date.now()) return null;
|
||||
return payload; // { role, exp }
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getCookie(request, name) {
|
||||
const header = request.headers.get("Cookie") || "";
|
||||
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
import { COOKIE_NAME, ROLE_COOKIE_NAME, SESSION_TAGE, signSession, verifySession, getCookie } from "./_shared/auth.js";
|
||||
|
||||
async function handleAuth(request, env) {
|
||||
let body;
|
||||
@@ -153,7 +100,7 @@ export async function onRequest(context) {
|
||||
// vom SITE_PUBLIC-Schalter unten. Sobald der Shop live geht, setzt man einfach das Secret
|
||||
// SITE_PUBLIC=true (wrangler pages secret put) — dann öffnet sich der Rest der Seite für alle,
|
||||
// aber /verwaltung/ bleibt weiterhin komplett hinter dem Zugangscode.
|
||||
const immerGeschuetzterPfad = url.pathname.startsWith("/verwaltung");
|
||||
const immerGeschuetzterPfad = url.pathname.startsWith("/verwaltung") || url.pathname.startsWith("/api/verwaltung");
|
||||
const sitePublic = env.SITE_PUBLIC === "true";
|
||||
if (sitePublic && !immerGeschuetzterPfad) {
|
||||
return next();
|
||||
@@ -181,6 +128,15 @@ export async function onRequest(context) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// API-Aufrufe (fetch() aus Checkout/Verwaltung) sollen bei fehlender Sitzung eine saubere
|
||||
// 401-JSON-Antwort bekommen statt eines 302 auf die HTML-Zugangscode-Seite (siehe Kopfkommentar).
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
return new Response(JSON.stringify({ ok: false, error: "Nicht angemeldet." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const nextParam = url.pathname + url.search;
|
||||
return Response.redirect(`${url.origin}/gate?next=${encodeURIComponent(nextParam)}`, 302);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/* =====================================================================
|
||||
functions/_shared/auth.js — gemeinsame Session-Logik für die Zugangscode-Schranke.
|
||||
|
||||
Ursprünglich komplett in functions/_middleware.js, jetzt hierher ausgelagert, damit auch
|
||||
die neuen API-Routen (functions/api/verwaltung/*) unabhängig vom Seiten-Routing prüfen
|
||||
können, ob eine gültige qciga-/VanVan-Sitzung vorliegt — Verteidigung in der Tiefe: selbst
|
||||
wenn jemand /api/verwaltung/orders direkt anspringt (ohne vorher /verwaltung/ zu laden),
|
||||
greift hier dieselbe Prüfung wie bei jeder normalen Seite.
|
||||
===================================================================== */
|
||||
|
||||
export const COOKIE_NAME = "vandiy_gate_session";
|
||||
export const ROLE_COOKIE_NAME = "vandiy_gate_role";
|
||||
export const SESSION_TAGE = 90;
|
||||
|
||||
function b64urlEncode(bytes) {
|
||||
let bin = "";
|
||||
bytes.forEach((b) => (bin += String.fromCharCode(b)));
|
||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
function b64urlDecodeToBytes(str) {
|
||||
str = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (str.length % 4) str += "=";
|
||||
const bin = atob(str);
|
||||
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function hmacKey(secret) {
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"]
|
||||
);
|
||||
}
|
||||
|
||||
export async function signSession(role, secret) {
|
||||
const payload = JSON.stringify({ role, exp: Date.now() + SESSION_TAGE * 24 * 60 * 60 * 1000 });
|
||||
const payloadB64 = b64urlEncode(new TextEncoder().encode(payload));
|
||||
const key = await hmacKey(secret);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payloadB64));
|
||||
const sigB64 = b64urlEncode(new Uint8Array(sig));
|
||||
return `${payloadB64}.${sigB64}`;
|
||||
}
|
||||
|
||||
export async function verifySession(token, secret) {
|
||||
if (!token || !token.includes(".")) return null;
|
||||
const [payloadB64, sigB64] = token.split(".");
|
||||
try {
|
||||
const key = await hmacKey(secret);
|
||||
const valid = await crypto.subtle.verify(
|
||||
"HMAC",
|
||||
key,
|
||||
b64urlDecodeToBytes(sigB64),
|
||||
new TextEncoder().encode(payloadB64)
|
||||
);
|
||||
if (!valid) return null;
|
||||
const payload = JSON.parse(new TextDecoder().decode(b64urlDecodeToBytes(payloadB64)));
|
||||
if (!payload.exp || payload.exp < Date.now()) return null;
|
||||
return payload; // { role, exp }
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCookie(request, name) {
|
||||
const header = request.headers.get("Cookie") || "";
|
||||
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
/** Liefert die Rolle ("qciga"/"vanvan") bei gültiger Sitzung, sonst null. Für API-Routen
|
||||
* gedacht, die UNABHÄNGIG vom Seiten-Routing (Middleware) noch einmal selbst prüfen wollen,
|
||||
* ob wirklich nur qciga/VanVan zugreifen — siehe functions/api/verwaltung/*. */
|
||||
export async function getGateRole(request, env) {
|
||||
if (!env.SITE_ACCESS_SECRET) return null;
|
||||
const token = getCookie(request, COOKIE_NAME);
|
||||
const payload = token ? await verifySession(token, env.SITE_ACCESS_SECRET) : null;
|
||||
return payload?.role ?? null;
|
||||
}
|
||||
|
||||
/** Standard-401-Antwort für API-Routen ohne gültige Sitzung. */
|
||||
export function unauthorizedJson() {
|
||||
return new Response(JSON.stringify({ ok: false, error: "Nicht angemeldet." }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function json(status, data) {
|
||||
return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/* =====================================================================
|
||||
POST /api/orders — legt eine echte Bestellung in D1 an (orders + order_items).
|
||||
|
||||
Wird vom Checkout aufgerufen (siehe src/pages/checkout/index.astro), sobald jemand die
|
||||
Bestellung abschickt. 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.
|
||||
|
||||
In Phase 1 gibt es kein echtes Zahlungs-Backend — jede vollständig abgeschickte Bestellung
|
||||
gilt sofort als "bezahlt" (siehe scripts/account.ts, derselbe Grundsatz). ===================================================================== */
|
||||
|
||||
import { json } from "../_shared/http.js";
|
||||
|
||||
const ERLAUBTE_LAENDER = ["de", "at", "ch", "lu"];
|
||||
|
||||
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();
|
||||
} catch {
|
||||
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;
|
||||
|
||||
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)
|
||||
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
|
||||
)
|
||||
.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);
|
||||
|
||||
return json(201, { ok: true, id: orderId, bestellnummer });
|
||||
} catch (err) {
|
||||
return json(500, { ok: false, error: "Bestellung konnte nicht gespeichert werden." });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/* =====================================================================
|
||||
GET /api/verwaltung/orders — liefert ALLE echten Bestellungen (+ Positionen) für die
|
||||
interne Verwaltungsseite (siehe src/pages/verwaltung/index.astro).
|
||||
|
||||
Zusätzlich zur Middleware (functions/_middleware.js, schützt /api/verwaltung/* IMMER,
|
||||
unabhängig von SITE_PUBLIC) prüft diese Route die Sitzung noch einmal selbst — Verteidigung
|
||||
in der Tiefe, falls diese Datei mal unabhängig von der Middleware aufgerufen wird. ===================================================================== */
|
||||
|
||||
import { getGateRole, unauthorizedJson } from "../../_shared/auth.js";
|
||||
import { json } from "../../_shared/http.js";
|
||||
|
||||
export async function onRequestGet(context) {
|
||||
const { request, env } = context;
|
||||
|
||||
const role = await getGateRole(request, env);
|
||||
if (!role) return unauthorizedJson();
|
||||
|
||||
if (!env.DB) {
|
||||
return json(500, { ok: false, error: "Datenbank nicht verbunden." });
|
||||
}
|
||||
|
||||
try {
|
||||
const { results: orders } = await env.DB.prepare(`SELECT * FROM orders ORDER BY created_at DESC`).all();
|
||||
|
||||
if (orders.length === 0) {
|
||||
return json(200, { ok: true, orders: [] });
|
||||
}
|
||||
|
||||
const ids = orders.map((o) => o.id);
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
const { results: items } = await env.DB
|
||||
.prepare(`SELECT * FROM order_items WHERE order_id IN (${placeholders}) ORDER BY id ASC`)
|
||||
.bind(...ids)
|
||||
.all();
|
||||
|
||||
const itemsByOrder = new Map();
|
||||
items.forEach((item) => {
|
||||
const liste = itemsByOrder.get(item.order_id) ?? [];
|
||||
liste.push(item);
|
||||
itemsByOrder.set(item.order_id, liste);
|
||||
});
|
||||
|
||||
const result = orders.map((o) => ({ ...o, artikel: itemsByOrder.get(o.id) ?? [] }));
|
||||
return json(200, { ok: true, orders: result });
|
||||
} catch (err) {
|
||||
return json(500, { ok: false, error: "Bestellungen konnten nicht geladen werden." });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/* =====================================================================
|
||||
PATCH /api/verwaltung/orders/:id — Status (und/oder Tracking-Nummer) einer Bestellung
|
||||
ändern, direkt aus der Verwaltungsseite heraus (siehe src/pages/verwaltung/index.astro).
|
||||
|
||||
Genau wie bei GET /api/verwaltung/orders: zusätzlich zur Middleware-Sperre wird die
|
||||
Sitzung hier noch einmal eigenständig geprüft. ===================================================================== */
|
||||
|
||||
import { getGateRole, unauthorizedJson } from "../../../_shared/auth.js";
|
||||
import { json } from "../../../_shared/http.js";
|
||||
|
||||
const ERLAUBTE_STATUS = ["bezahlt", "bearbeitung", "versandVorbereitet", "versendet", "abgeschlossen", "storniert"];
|
||||
|
||||
export async function onRequestPatch(context) {
|
||||
const { request, env, params } = context;
|
||||
|
||||
const role = await getGateRole(request, env);
|
||||
if (!role) return unauthorizedJson();
|
||||
|
||||
if (!env.DB) {
|
||||
return json(500, { ok: false, error: "Datenbank nicht verbunden." });
|
||||
}
|
||||
|
||||
const id = Number(params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return json(400, { ok: false, error: "Ungültige Bestell-ID." });
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json(400, { ok: false, error: "Ungültige Anfrage." });
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
const values = [];
|
||||
|
||||
if (body.status !== undefined) {
|
||||
if (!ERLAUBTE_STATUS.includes(body.status)) {
|
||||
return json(400, { ok: false, error: "Ungültiger Status." });
|
||||
}
|
||||
updates.push("status = ?");
|
||||
values.push(body.status);
|
||||
}
|
||||
if (body.tracking !== undefined) {
|
||||
updates.push("tracking = ?");
|
||||
values.push(String(body.tracking || ""));
|
||||
}
|
||||
if (updates.length === 0) {
|
||||
return json(400, { ok: false, error: "Nichts zu ändern übergeben." });
|
||||
}
|
||||
|
||||
updates.push("updated_at = ?");
|
||||
values.push(new Date().toISOString());
|
||||
values.push(id);
|
||||
|
||||
try {
|
||||
const result = await env.DB.prepare(`UPDATE orders SET ${updates.join(", ")} WHERE id = ?`)
|
||||
.bind(...values)
|
||||
.run();
|
||||
if (result.meta.changes === 0) {
|
||||
return json(404, { ok: false, error: "Bestellung nicht gefunden." });
|
||||
}
|
||||
return json(200, { ok: true });
|
||||
} catch (err) {
|
||||
return json(500, { ok: false, error: "Bestellung konnte nicht aktualisiert werden." });
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,7 @@ const t = useTranslations(lang);
|
||||
<script type="module">
|
||||
import { getCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon, appliedPartnerCode, partnerCodeDiscount, setPartnerCode, clearPartnerCode, totalDiscount, treuebonusDiscount, aboDiscount, istAusgewaehlt, zeilenpreisFuer } from "../../../scripts/cart";
|
||||
import { formatPrice } from "../../../i18n/format";
|
||||
import { products } from "../../../data/products";
|
||||
import { products, getProduct } from "../../../data/products";
|
||||
import { berechneVersandkosten } from "../../../data/versand";
|
||||
import { bankverbindung, bankverbindungVollstaendig } from "../../../data/bankverbindung";
|
||||
import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../../scripts/account";
|
||||
@@ -194,6 +194,8 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
const cart = getCart().filter(istAusgewaehlt);
|
||||
const subtotal = cartTotal();
|
||||
let currentTotal = 0;
|
||||
let currentShipping = 0;
|
||||
let currentRabattGesamt = 0;
|
||||
|
||||
function renderSummary() {
|
||||
const land = landSelect.value;
|
||||
@@ -206,6 +208,8 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
const aboDiscountBetrag = aboDiscount(subtotal);
|
||||
const total = Math.max(0, subtotal - totalDiscount(subtotal)) + shipping;
|
||||
currentTotal = total;
|
||||
currentShipping = shipping;
|
||||
currentRabattGesamt = totalDiscount(subtotal);
|
||||
|
||||
summary.innerHTML = `
|
||||
${cart.map((i) => {
|
||||
@@ -331,20 +335,72 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
renderSummary();
|
||||
});
|
||||
|
||||
document.getElementById("checkout-form").addEventListener("submit", (e) => {
|
||||
const orderForm = document.getElementById("checkout-form");
|
||||
const submitBtn = orderForm.querySelector('button[type="submit"]');
|
||||
const orderErrorEl = document.createElement("p");
|
||||
orderErrorEl.className = "small";
|
||||
orderErrorEl.style.color = "var(--c-sale)";
|
||||
orderErrorEl.style.display = "none";
|
||||
submitBtn.insertAdjacentElement("beforebegin", orderErrorEl);
|
||||
|
||||
orderForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (cart.length === 0) {
|
||||
alert(emptyCartAlert);
|
||||
return;
|
||||
}
|
||||
// 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();
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/orders", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
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;
|
||||
} catch (err) {
|
||||
orderErrorEl.textContent = "⚠️ " + (err instanceof Error ? err.message : "Bestellung konnte nicht gespeichert werden. Bitte nochmal versuchen.");
|
||||
orderErrorEl.style.display = "block";
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = urspruenglicherBtnText;
|
||||
}
|
||||
location.href = thankYouPath;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -183,7 +183,7 @@ const t = useTranslations(lang);
|
||||
<script type="module">
|
||||
import { getCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon, appliedPartnerCode, partnerCodeDiscount, setPartnerCode, clearPartnerCode, totalDiscount, treuebonusDiscount, aboDiscount, istAusgewaehlt, zeilenpreisFuer } from "../../scripts/cart";
|
||||
import { formatPrice } from "../../i18n/format";
|
||||
import { products } from "../../data/products";
|
||||
import { products, getProduct } from "../../data/products";
|
||||
import { berechneVersandkosten } from "../../data/versand";
|
||||
import { bankverbindung, bankverbindungVollstaendig } from "../../data/bankverbindung";
|
||||
import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../scripts/account";
|
||||
@@ -197,6 +197,10 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
const cart = getCart().filter(istAusgewaehlt);
|
||||
const subtotal = cartTotal();
|
||||
let currentTotal = 0;
|
||||
// Für den echten Bestell-API-Aufruf beim Absenden gebraucht (siehe Submit-Handler unten) —
|
||||
// renderSummary() aktualisiert diese bei jeder Land-/Gutschein-Änderung mit.
|
||||
let currentShipping = 0;
|
||||
let currentRabattGesamt = 0;
|
||||
|
||||
function renderSummary() {
|
||||
const land = landSelect.value;
|
||||
@@ -209,6 +213,8 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
const aboDiscountBetrag = aboDiscount(subtotal);
|
||||
const total = Math.max(0, subtotal - totalDiscount(subtotal)) + shipping;
|
||||
currentTotal = total;
|
||||
currentShipping = shipping;
|
||||
currentRabattGesamt = totalDiscount(subtotal);
|
||||
|
||||
summary.innerHTML = `
|
||||
${cart.map((i) => {
|
||||
@@ -334,22 +340,82 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
renderSummary();
|
||||
});
|
||||
|
||||
document.getElementById("checkout-form").addEventListener("submit", (e) => {
|
||||
const orderForm = document.getElementById("checkout-form");
|
||||
const submitBtn = orderForm.querySelector('button[type="submit"]');
|
||||
const orderErrorEl = document.createElement("p");
|
||||
orderErrorEl.className = "small";
|
||||
orderErrorEl.style.color = "var(--c-sale)";
|
||||
orderErrorEl.style.display = "none";
|
||||
submitBtn.insertAdjacentElement("beforebegin", orderErrorEl);
|
||||
|
||||
orderForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (cart.length === 0) {
|
||||
alert(emptyCartAlert);
|
||||
return;
|
||||
}
|
||||
// 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. Nur eine erfolgreich abgeschickte,
|
||||
// vollständig bezahlte Bestellung zählt hier; abgebrochene/nicht abgeschickte Bestellungen
|
||||
// rufen diese Funktionen nie auf.
|
||||
if (treuebonusDiscount() > 0) {
|
||||
treuebonusEinloesen();
|
||||
} else {
|
||||
registriereAbgeschlosseneBestellung();
|
||||
|
||||
// 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.
|
||||
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,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/orders", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
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;
|
||||
} catch (err) {
|
||||
orderErrorEl.textContent = "⚠️ " + (err instanceof Error ? err.message : "Bestellung konnte nicht gespeichert werden. Bitte nochmal versuchen.");
|
||||
orderErrorEl.style.display = "block";
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = urspruenglicherBtnText;
|
||||
}
|
||||
location.href = thankYouPath;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -180,7 +180,7 @@ const t = useTranslations(lang);
|
||||
<script type="module">
|
||||
import { getCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon, appliedPartnerCode, partnerCodeDiscount, setPartnerCode, clearPartnerCode, totalDiscount, treuebonusDiscount, aboDiscount, istAusgewaehlt, zeilenpreisFuer } from "../../../scripts/cart";
|
||||
import { formatPrice } from "../../../i18n/format";
|
||||
import { products } from "../../../data/products";
|
||||
import { products, getProduct } from "../../../data/products";
|
||||
import { berechneVersandkosten } from "../../../data/versand";
|
||||
import { bankverbindung, bankverbindungVollstaendig } from "../../../data/bankverbindung";
|
||||
import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../../scripts/account";
|
||||
@@ -194,6 +194,8 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
const cart = getCart().filter(istAusgewaehlt);
|
||||
const subtotal = cartTotal();
|
||||
let currentTotal = 0;
|
||||
let currentShipping = 0;
|
||||
let currentRabattGesamt = 0;
|
||||
|
||||
function renderSummary() {
|
||||
const land = landSelect.value;
|
||||
@@ -206,6 +208,8 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
const aboDiscountBetrag = aboDiscount(subtotal);
|
||||
const total = Math.max(0, subtotal - totalDiscount(subtotal)) + shipping;
|
||||
currentTotal = total;
|
||||
currentShipping = shipping;
|
||||
currentRabattGesamt = totalDiscount(subtotal);
|
||||
|
||||
summary.innerHTML = `
|
||||
${cart.map((i) => {
|
||||
@@ -331,20 +335,72 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
renderSummary();
|
||||
});
|
||||
|
||||
document.getElementById("checkout-form").addEventListener("submit", (e) => {
|
||||
const orderForm = document.getElementById("checkout-form");
|
||||
const submitBtn = orderForm.querySelector('button[type="submit"]');
|
||||
const orderErrorEl = document.createElement("p");
|
||||
orderErrorEl.className = "small";
|
||||
orderErrorEl.style.color = "var(--c-sale)";
|
||||
orderErrorEl.style.display = "none";
|
||||
submitBtn.insertAdjacentElement("beforebegin", orderErrorEl);
|
||||
|
||||
orderForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (cart.length === 0) {
|
||||
alert(emptyCartAlert);
|
||||
return;
|
||||
}
|
||||
// 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();
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/orders", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
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;
|
||||
} catch (err) {
|
||||
orderErrorEl.textContent = "⚠️ " + (err instanceof Error ? err.message : "Bestellung konnte nicht gespeichert werden. Bitte nochmal versuchen.");
|
||||
orderErrorEl.style.display = "block";
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = urspruenglicherBtnText;
|
||||
}
|
||||
location.href = thankYouPath;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -180,7 +180,7 @@ const t = useTranslations(lang);
|
||||
<script type="module">
|
||||
import { getCart, cartTotal, appliedCoupon, couponDiscount, setCouponCode, clearCoupon, appliedPartnerCode, partnerCodeDiscount, setPartnerCode, clearPartnerCode, totalDiscount, treuebonusDiscount, aboDiscount, istAusgewaehlt, zeilenpreisFuer } from "../../../scripts/cart";
|
||||
import { formatPrice } from "../../../i18n/format";
|
||||
import { products } from "../../../data/products";
|
||||
import { products, getProduct } from "../../../data/products";
|
||||
import { berechneVersandkosten } from "../../../data/versand";
|
||||
import { bankverbindung, bankverbindungVollstaendig } from "../../../data/bankverbindung";
|
||||
import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../../scripts/account";
|
||||
@@ -194,6 +194,8 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
const cart = getCart().filter(istAusgewaehlt);
|
||||
const subtotal = cartTotal();
|
||||
let currentTotal = 0;
|
||||
let currentShipping = 0;
|
||||
let currentRabattGesamt = 0;
|
||||
|
||||
function renderSummary() {
|
||||
const land = landSelect.value;
|
||||
@@ -206,6 +208,8 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
const aboDiscountBetrag = aboDiscount(subtotal);
|
||||
const total = Math.max(0, subtotal - totalDiscount(subtotal)) + shipping;
|
||||
currentTotal = total;
|
||||
currentShipping = shipping;
|
||||
currentRabattGesamt = totalDiscount(subtotal);
|
||||
|
||||
summary.innerHTML = `
|
||||
${cart.map((i) => {
|
||||
@@ -331,20 +335,72 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
renderSummary();
|
||||
});
|
||||
|
||||
document.getElementById("checkout-form").addEventListener("submit", (e) => {
|
||||
const orderForm = document.getElementById("checkout-form");
|
||||
const submitBtn = orderForm.querySelector('button[type="submit"]');
|
||||
const orderErrorEl = document.createElement("p");
|
||||
orderErrorEl.className = "small";
|
||||
orderErrorEl.style.color = "var(--c-sale)";
|
||||
orderErrorEl.style.display = "none";
|
||||
submitBtn.insertAdjacentElement("beforebegin", orderErrorEl);
|
||||
|
||||
orderForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (cart.length === 0) {
|
||||
alert(emptyCartAlert);
|
||||
return;
|
||||
}
|
||||
// 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();
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/orders", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
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;
|
||||
} catch (err) {
|
||||
orderErrorEl.textContent = "⚠️ " + (err instanceof Error ? err.message : "Bestellung konnte nicht gespeichert werden. Bitte nochmal versuchen.");
|
||||
orderErrorEl.style.display = "block";
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = urspruenglicherBtnText;
|
||||
}
|
||||
location.href = thankYouPath;
|
||||
});
|
||||
</script>
|
||||
|
||||
+325
-227
@@ -1,125 +1,18 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { getProduct } from "../../data/products";
|
||||
import { getCategory } from "../../data/categories";
|
||||
import { formatPrice } from "../../i18n/format";
|
||||
import type { Locale } from "../../i18n/config";
|
||||
|
||||
const lang: Locale = "de";
|
||||
|
||||
// ─── Interne Bestellverwaltung — NUR für qciga/vanvan (siehe functions/_middleware.js, diese
|
||||
// Seite bleibt immer hinter dem Zugangscode, auch wenn der Rest der Seite mal öffentlich wird). ───
|
||||
// Seite bleibt immer hinter dem Zugangscode, auch wenn der Rest der Seite mal öffentlich wird,
|
||||
// UND zusätzlich schützt dieselbe Middleware die dazugehörige API unter /api/verwaltung/*). ───
|
||||
//
|
||||
// TODO Phase 2: Es gibt noch keine echte, geräteübergreifende Bestell-Datenbank (siehe
|
||||
// previewNote in konto/angemeldet.astro) — Bestellungen aus dem Warenkorb landen aktuell nirgends
|
||||
// zentral, jede Person sieht nur ihre eigenen im eigenen Browser. Diese Seite zeigt deshalb
|
||||
// realistische BEISPIELDATEN, exakt in der Struktur, die eine echte Bestellliste später haben
|
||||
// wird (Cloudflare Worker + D1, siehe Tech-Stack-Entscheidung) — Layout/Auswertung sind schon
|
||||
// jetzt "fertig", es muss später nur noch echte Daten statt der Beispiele einspeisen.
|
||||
|
||||
type Status = "bezahlt" | "bearbeitung" | "versandVorbereitet" | "versendet" | "abgeschlossen";
|
||||
type Land = "de" | "at" | "ch" | "lu";
|
||||
|
||||
interface BeispielBestellung {
|
||||
nummer: string;
|
||||
datum: string; // YYYY-MM-DD
|
||||
land: Land;
|
||||
status: Status;
|
||||
artikel: { slug: string; menge: number }[];
|
||||
}
|
||||
|
||||
const bestellungenRoh: BeispielBestellung[] = [
|
||||
{ nummer: "VDB-2026-0201", datum: "2026-08-02", land: "de", status: "bezahlt", artikel: [{ slug: "kleiner-teddy", menge: 2 }] },
|
||||
{ nummer: "VDB-2026-0200", datum: "2026-08-02", land: "at", status: "bearbeitung", artikel: [{ slug: "haekeldecke-boho", menge: 1 }, { slug: "baumwollgarn-bundle", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0199", datum: "2026-08-01", land: "de", status: "versandVorbereitet", artikel: [{ slug: "hasidog-plushie-blaueroverall", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0198", datum: "2026-08-01", land: "ch", status: "versendet", artikel: [{ slug: "makramee-ohrringe", menge: 2 }, { slug: "perlen-armband-hellblau", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0197", datum: "2026-07-31", land: "de", status: "abgeschlossen", artikel: [{ slug: "grosser-teddy", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0196", datum: "2026-07-31", land: "de", status: "abgeschlossen", artikel: [{ slug: "fuchs-plushie-mini", menge: 3 }] },
|
||||
{ nummer: "VDB-2026-0195", datum: "2026-07-30", land: "lu", status: "bezahlt", artikel: [{ slug: "stoffreste-bundle", menge: 1 }, { slug: "haekelnadel-set-ergonomisch", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0194", datum: "2026-07-30", land: "de", status: "abgeschlossen", artikel: [{ slug: "wolke-plushie-lavendel", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0193", datum: "2026-07-29", land: "at", status: "versendet", artikel: [{ slug: "kleiner-teddy", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0192", datum: "2026-07-29", land: "de", status: "abgeschlossen", artikel: [{ slug: "hasidog-plushie-blaueroverall", menge: 2 }] },
|
||||
{ nummer: "VDB-2026-0191", datum: "2026-07-28", land: "ch", status: "bearbeitung", artikel: [{ slug: "haekel-untersetzer-set", menge: 1 }, { slug: "baumwollgarn-bundle", menge: 2 }] },
|
||||
{ nummer: "VDB-2026-0190", datum: "2026-07-28", land: "de", status: "abgeschlossen", artikel: [{ slug: "perlen-armband-hellblau", menge: 3 }] },
|
||||
{ nummer: "VDB-2026-0189", datum: "2026-07-27", land: "de", status: "abgeschlossen", artikel: [{ slug: "fuchs-plushie-mini", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0188", datum: "2026-07-26", land: "de", status: "abgeschlossen", artikel: [{ slug: "grosser-teddy", menge: 2 }] },
|
||||
{ nummer: "VDB-2026-0187", datum: "2026-07-25", land: "at", status: "abgeschlossen", artikel: [{ slug: "makramee-ohrringe", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0186", datum: "2026-07-24", land: "de", status: "abgeschlossen", artikel: [{ slug: "hasidog-plushie-blaueroverall", menge: 1 }, { slug: "kleiner-teddy", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0185", datum: "2026-07-23", land: "ch", status: "abgeschlossen", artikel: [{ slug: "haekeldecke-boho", menge: 1 }] },
|
||||
{ nummer: "VDB-2026-0184", datum: "2026-07-22", land: "de", status: "versendet", artikel: [{ slug: "stoffreste-bundle", menge: 2 }] },
|
||||
{ nummer: "VDB-2026-0183", datum: "2026-07-21", land: "de", status: "abgeschlossen", artikel: [{ slug: "fuchs-plushie-mini", menge: 2 }] },
|
||||
{ nummer: "VDB-2026-0182", datum: "2026-07-20", land: "lu", status: "abgeschlossen", artikel: [{ slug: "wolke-plushie-lavendel", menge: 1 }, { slug: "perlen-armband-hellblau", menge: 1 }] },
|
||||
];
|
||||
|
||||
const bestellungen = bestellungenRoh.map((b) => {
|
||||
const artikel = b.artikel.map((a) => {
|
||||
const p = getProduct(a.slug);
|
||||
return { ...a, name: p?.name[lang] ?? a.slug, preis: p?.preis ?? 0, kategorie: p?.kategorie ?? "" };
|
||||
});
|
||||
return { ...b, artikel, summe: artikel.reduce((s, a) => s + a.preis * a.menge, 0) };
|
||||
});
|
||||
|
||||
// Vier Buckets statt der fünf feingranularen Status — genau wie gewünscht: offen / in Bearbeitung
|
||||
// / versendet / abgeschlossen. "bearbeitung" + "versandVorbereitet" zählen zusammen als
|
||||
// "in Bearbeitung" (beides "wird gerade vorbereitet", nur mit unterschiedlichem Detailgrad).
|
||||
function bucket(status: Status): "offen" | "inBearbeitung" | "versendet" | "abgeschlossen" {
|
||||
if (status === "bezahlt") return "offen";
|
||||
if (status === "bearbeitung" || status === "versandVorbereitet") return "inBearbeitung";
|
||||
if (status === "versendet") return "versendet";
|
||||
return "abgeschlossen";
|
||||
}
|
||||
const buckets = { offen: 0, inBearbeitung: 0, versendet: 0, abgeschlossen: 0 };
|
||||
bestellungen.forEach((b) => { buckets[bucket(b.status)]++; });
|
||||
|
||||
const gesamtumsatz = bestellungen.reduce((s, b) => s + b.summe, 0);
|
||||
const abgeschlosseneUmsaetze = bestellungen.filter((b) => b.status === "abgeschlossen");
|
||||
const durchschnittsbestellwert = bestellungen.length > 0 ? gesamtumsatz / bestellungen.length : 0;
|
||||
|
||||
const statusLabel: Record<Status, string> = {
|
||||
bezahlt: "Bezahlt", bearbeitung: "In Bearbeitung", versandVorbereitet: "Versand wird vorbereitet",
|
||||
versendet: "Versendet", abgeschlossen: "Abgeschlossen",
|
||||
};
|
||||
|
||||
// ── Auswertung 1: meistverkaufte Produkte je Kategorie (nach verkaufter Menge) ──
|
||||
const mengeProSlug = new Map<string, number>();
|
||||
bestellungen.forEach((b) => b.artikel.forEach((a) => mengeProSlug.set(a.slug, (mengeProSlug.get(a.slug) ?? 0) + a.menge)));
|
||||
|
||||
const kategorienMitProdukten = new Map<string, { slug: string; name: string; menge: number }[]>();
|
||||
mengeProSlug.forEach((menge, slug) => {
|
||||
const p = getProduct(slug);
|
||||
if (!p) return;
|
||||
const liste = kategorienMitProdukten.get(p.kategorie) ?? [];
|
||||
liste.push({ slug, name: p.name[lang], menge });
|
||||
kategorienMitProdukten.set(p.kategorie, liste);
|
||||
});
|
||||
const auswertungKategorien = Array.from(kategorienMitProdukten.entries())
|
||||
.map(([kategorieSlug, produkte]) => {
|
||||
const kat = getCategory(kategorieSlug);
|
||||
const sortiert = produkte.sort((a, b) => b.menge - a.menge);
|
||||
const maxMenge = sortiert[0]?.menge ?? 1;
|
||||
return {
|
||||
slug: kategorieSlug,
|
||||
name: kat?.name[lang] ?? kategorieSlug,
|
||||
icon: kat?.icon ?? "📦",
|
||||
gesamtMenge: sortiert.reduce((s, p) => s + p.menge, 0),
|
||||
produkte: sortiert.slice(0, 4).map((p) => ({ ...p, prozent: Math.round((p.menge / maxMenge) * 100) })),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.gesamtMenge - a.gesamtMenge);
|
||||
|
||||
// ── Auswertung 2: Bestellungen nach Land ──
|
||||
const landName: Record<Land, string> = { de: "Deutschland", at: "Österreich", ch: "Schweiz", lu: "Luxemburg" };
|
||||
const landFlagge: Record<Land, string> = { de: "🇩🇪", at: "🇦🇹", ch: "🇨🇭", lu: "🇱🇺" };
|
||||
const landCounts = new Map<Land, number>();
|
||||
bestellungen.forEach((b) => landCounts.set(b.land, (landCounts.get(b.land) ?? 0) + 1));
|
||||
const maxLandCount = Math.max(...Array.from(landCounts.values()));
|
||||
const auswertungLaender = (Array.from(landCounts.entries()) as [Land, number][])
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([land, count]) => ({
|
||||
land, name: landName[land], flagge: landFlagge[land], count,
|
||||
prozent: Math.round((count / bestellungen.length) * 100),
|
||||
balken: Math.round((count / maxLandCount) * 100),
|
||||
}));
|
||||
// Echtes Bestellsystem seit Phase 2 (Cloudflare D1, siehe functions/api/orders.js +
|
||||
// functions/api/verwaltung/): Diese Seite selbst ist als statische Astro-Seite gebaut (wie die
|
||||
// ganze restliche Seite), lädt die eigentlichen Bestelldaten aber zur LAUFZEIT per fetch() aus
|
||||
// der Datenbank (siehe <script> unten) — es gibt hier also KEINE Beispieldaten mehr, sondern
|
||||
// echte, live einlaufende Bestellungen aus dem Checkout.
|
||||
---
|
||||
<Layout
|
||||
title="Bestellverwaltung"
|
||||
@@ -131,11 +24,9 @@ const auswertungLaender = (Array.from(landCounts.entries()) as [Land, number][])
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<p class="todo-note">
|
||||
🔒 Interner Bereich, nur für dich und VanVan sichtbar (siehe Zugangscode-Gate). Es gibt
|
||||
noch keine echte, geräteübergreifende Bestell-Datenbank (Phase 1) — die Bestellungen und
|
||||
Auswertungen unten sind realistische BEISPIELDATEN, damit du siehst, wie die Übersicht
|
||||
aussehen wird. Echte Bestellungen laufen automatisch hier ein, sobald die Zahlungs- und
|
||||
Datenbank-Anbindung (Cloudflare Worker + D1) in Phase 2 steht.
|
||||
🔒 Interner Bereich, nur für dich und VanVan sichtbar (siehe Zugangscode-Gate) — bleibt
|
||||
so, auch wenn der Rest der Seite mal öffentlich wird. Alles unten sind ECHTE
|
||||
Bestellungen aus der Datenbank, live vom Checkout befüllt.
|
||||
</p>
|
||||
<span class="eyebrow">Verwaltung</span>
|
||||
<h1>📊 Bestellverwaltung</h1>
|
||||
@@ -143,133 +34,340 @@ const auswertungLaender = (Array.from(landCounts.entries()) as [Land, number][])
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<section class="section-tight" id="verwaltung-loading">
|
||||
<div class="container">
|
||||
<div class="verwaltung-stat-grid">
|
||||
<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">{buckets.offen}</span>
|
||||
<span class="verwaltung-stat-label">Offen</span>
|
||||
</button>
|
||||
<button type="button" class="card verwaltung-stat-tile" data-filter="inBearbeitung">
|
||||
<span class="verwaltung-stat-icon" aria-hidden="true">⚙️</span>
|
||||
<span class="verwaltung-stat-value">{buckets.inBearbeitung}</span>
|
||||
<span class="verwaltung-stat-label">In Bearbeitung</span>
|
||||
</button>
|
||||
<button type="button" class="card verwaltung-stat-tile" data-filter="versendet">
|
||||
<span class="verwaltung-stat-icon" aria-hidden="true">🚚</span>
|
||||
<span class="verwaltung-stat-value">{buckets.versendet}</span>
|
||||
<span class="verwaltung-stat-label">Versendet</span>
|
||||
</button>
|
||||
<button type="button" class="card verwaltung-stat-tile" data-filter="abgeschlossen">
|
||||
<span class="verwaltung-stat-icon" aria-hidden="true">✅</span>
|
||||
<span class="verwaltung-stat-value">{buckets.abgeschlossen}</span>
|
||||
<span class="verwaltung-stat-label">Abgeschlossen</span>
|
||||
</button>
|
||||
<div class="card verwaltung-stat-tile verwaltung-stat-tile-highlight">
|
||||
<span class="verwaltung-stat-icon" aria-hidden="true">💶</span>
|
||||
<span class="verwaltung-stat-value">{formatPrice(gesamtumsatz, lang)}</span>
|
||||
<span class="verwaltung-stat-label">Gesamtumsatz ({bestellungen.length} Bestellungen)</span>
|
||||
<p class="small">⏳ Bestellungen werden geladen …</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight" id="verwaltung-error" style="display:none;">
|
||||
<div class="container">
|
||||
<p class="todo-note" style="border-color: var(--c-sale); color: var(--c-sale);" id="verwaltung-error-text"></p>
|
||||
<button type="button" class="btn btn-outline btn-sm" id="verwaltung-retry-btn">🔄 Erneut versuchen</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="verwaltung-content" style="display:none;">
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<div class="verwaltung-stat-grid">
|
||||
<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>
|
||||
<span class="verwaltung-stat-label">Offen</span>
|
||||
</button>
|
||||
<button type="button" class="card verwaltung-stat-tile" data-filter="inBearbeitung">
|
||||
<span class="verwaltung-stat-icon" aria-hidden="true">⚙️</span>
|
||||
<span class="verwaltung-stat-value" id="stat-inBearbeitung">0</span>
|
||||
<span class="verwaltung-stat-label">In Bearbeitung</span>
|
||||
</button>
|
||||
<button type="button" class="card verwaltung-stat-tile" data-filter="versendet">
|
||||
<span class="verwaltung-stat-icon" aria-hidden="true">🚚</span>
|
||||
<span class="verwaltung-stat-value" id="stat-versendet">0</span>
|
||||
<span class="verwaltung-stat-label">Versendet</span>
|
||||
</button>
|
||||
<button type="button" class="card verwaltung-stat-tile" data-filter="abgeschlossen">
|
||||
<span class="verwaltung-stat-icon" aria-hidden="true">✅</span>
|
||||
<span class="verwaltung-stat-value" id="stat-abgeschlossen">0</span>
|
||||
<span class="verwaltung-stat-label">Abgeschlossen</span>
|
||||
</button>
|
||||
<div class="card verwaltung-stat-tile verwaltung-stat-tile-highlight">
|
||||
<span class="verwaltung-stat-icon" aria-hidden="true">💶</span>
|
||||
<span class="verwaltung-stat-value" id="stat-umsatz">–</span>
|
||||
<span class="verwaltung-stat-label" id="stat-umsatz-label">Gesamtumsatz</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<h2>📦 Alle Bestellungen</h2>
|
||||
<nav class="verwaltung-filter" aria-label="Nach Status filtern">
|
||||
<button type="button" class="verwaltung-filter-btn is-active" data-filter="alle">Alle ({bestellungen.length})</button>
|
||||
<button type="button" class="verwaltung-filter-btn" data-filter="offen">Offen ({buckets.offen})</button>
|
||||
<button type="button" class="verwaltung-filter-btn" data-filter="inBearbeitung">In Bearbeitung ({buckets.inBearbeitung})</button>
|
||||
<button type="button" class="verwaltung-filter-btn" data-filter="versendet">Versendet ({buckets.versendet})</button>
|
||||
<button type="button" class="verwaltung-filter-btn" data-filter="abgeschlossen">Abgeschlossen ({buckets.abgeschlossen})</button>
|
||||
</nav>
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<h2>📦 Alle Bestellungen</h2>
|
||||
<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="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>
|
||||
<button type="button" class="verwaltung-filter-btn" data-filter="abgeschlossen">Abgeschlossen (<span id="filter-count-abgeschlossen">0</span>)</button>
|
||||
<button type="button" class="verwaltung-filter-btn" data-filter="storniert">Storniert (<span id="filter-count-storniert">0</span>)</button>
|
||||
</nav>
|
||||
|
||||
<div class="order-list" id="verwaltung-order-list">
|
||||
{bestellungen.map((b) => (
|
||||
<div class={`card order-card order-card-${b.status}`} data-bucket={bucket(b.status)}>
|
||||
<div class="order-card-head">
|
||||
<div>
|
||||
<span class="order-no">{landFlagge[b.land]} {b.nummer}</span>
|
||||
<span class="small order-meta">{b.datum} · {landName[b.land]}</span>
|
||||
</div>
|
||||
<span class={`badge order-status order-status-${b.status}`}>{statusLabel[b.status]}</span>
|
||||
</div>
|
||||
<ul class="order-items">
|
||||
{b.artikel.map((a) => (
|
||||
<li><span>{a.menge}× {a.name}</span><span class="order-item-price">{formatPrice(a.preis * a.menge, lang)}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
<div class="order-card-foot">
|
||||
<span class="order-total">Summe: <strong>{formatPrice(b.summe, lang)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p class="small" id="verwaltung-empty" style="display:none;">Noch keine Bestellungen eingegangen.</p>
|
||||
<div class="order-list" id="verwaltung-order-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<h2>🏆 Meistverkaufte Produkte je Kategorie</h2>
|
||||
<div class="verwaltung-auswertung-grid">
|
||||
{auswertungKategorien.map((k) => (
|
||||
<div class="card verwaltung-kat-card">
|
||||
<h3>{k.icon} {k.name}</h3>
|
||||
<div class="verwaltung-bar-list">
|
||||
{k.produkte.map((p) => (
|
||||
<div class="verwaltung-bar-row">
|
||||
<div class="verwaltung-bar-head"><span>{p.name}</span><span class="small">{p.menge}× verkauft</span></div>
|
||||
<div class="verwaltung-bar-track"><div class="verwaltung-bar-fill" style={`width:${p.prozent}%`}></div></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<section class="section-tight" id="verwaltung-auswertung-section">
|
||||
<div class="container">
|
||||
<h2>🏆 Meistverkaufte Produkte je Kategorie</h2>
|
||||
<p class="small" id="verwaltung-kat-empty" style="display:none;">Noch keine ausgewerteten Verkäufe (stornierte Bestellungen zählen nicht mit).</p>
|
||||
<div class="verwaltung-auswertung-grid" id="verwaltung-kat-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<h2>🌍 Bestellungen nach Land</h2>
|
||||
<div class="card verwaltung-kat-card">
|
||||
<div class="verwaltung-bar-list">
|
||||
{auswertungLaender.map((l) => (
|
||||
<div class="verwaltung-bar-row">
|
||||
<div class="verwaltung-bar-head"><span>{l.flagge} {l.name}</span><span class="small">{l.count} Bestellungen · {l.prozent}%</span></div>
|
||||
<div class="verwaltung-bar-track"><div class="verwaltung-bar-fill verwaltung-bar-fill-land" style={`width:${l.balken}%`}></div></div>
|
||||
</div>
|
||||
))}
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<h2>🌍 Bestellungen nach Land</h2>
|
||||
<div class="card verwaltung-kat-card">
|
||||
<div class="verwaltung-bar-list" id="verwaltung-land-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
const filterBtns = document.querySelectorAll<HTMLButtonElement>("[data-filter]");
|
||||
const cards = Array.from(document.querySelectorAll<HTMLElement>("#verwaltung-order-list .order-card"));
|
||||
<script type="module">
|
||||
import { getCategory } from "../../data/categories";
|
||||
import { formatPrice } from "../../i18n/format";
|
||||
|
||||
function setFilter(filter: string) {
|
||||
document.querySelectorAll<HTMLButtonElement>(".verwaltung-filter-btn").forEach((btn) => {
|
||||
const lang = "de";
|
||||
const STATUS_LABEL = {
|
||||
bezahlt: "Bezahlt",
|
||||
bearbeitung: "In Bearbeitung",
|
||||
versandVorbereitet: "Versand wird vorbereitet",
|
||||
versendet: "Versendet",
|
||||
abgeschlossen: "Abgeschlossen",
|
||||
storniert: "Storniert",
|
||||
};
|
||||
const LAND_NAME = { de: "Deutschland", at: "Österreich", ch: "Schweiz", lu: "Luxemburg" };
|
||||
const LAND_FLAGGE = { de: "🇩🇪", at: "🇦🇹", ch: "🇨🇭", lu: "🇱🇺" };
|
||||
|
||||
function bucket(status) {
|
||||
if (status === "storniert") return "storniert";
|
||||
if (status === "bezahlt") return "offen";
|
||||
if (status === "bearbeitung" || status === "versandVorbereitet") return "inBearbeitung";
|
||||
if (status === "versendet") return "versendet";
|
||||
return "abgeschlossen";
|
||||
}
|
||||
|
||||
const loadingEl = document.getElementById("verwaltung-loading");
|
||||
const errorEl = document.getElementById("verwaltung-error");
|
||||
const errorTextEl = document.getElementById("verwaltung-error-text");
|
||||
const contentEl = document.getElementById("verwaltung-content");
|
||||
|
||||
let alleBestellungen = [];
|
||||
|
||||
async function ladeUndRendere() {
|
||||
loadingEl.style.display = "";
|
||||
errorEl.style.display = "none";
|
||||
contentEl.style.display = "none";
|
||||
try {
|
||||
const res = await fetch("/api/verwaltung/orders");
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok || !data?.ok) {
|
||||
throw new Error(data?.error || `Server antwortete mit Status ${res.status}.`);
|
||||
}
|
||||
alleBestellungen = data.orders;
|
||||
render();
|
||||
loadingEl.style.display = "none";
|
||||
contentEl.style.display = "";
|
||||
} catch (err) {
|
||||
loadingEl.style.display = "none";
|
||||
errorEl.style.display = "";
|
||||
errorTextEl.textContent = "⚠️ Bestellungen konnten nicht geladen werden: " + (err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function setText(id, text) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const aktive = alleBestellungen.filter((b) => b.status !== "storniert");
|
||||
|
||||
// ── Buckets für die Stat-Kacheln + Filterleiste ──
|
||||
const buckets = { offen: 0, inBearbeitung: 0, versendet: 0, abgeschlossen: 0, storniert: 0 };
|
||||
alleBestellungen.forEach((b) => { buckets[bucket(b.status)]++; });
|
||||
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-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: stornierte Bestellungen zählen bewusst NICHT mit. ──
|
||||
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"})`);
|
||||
|
||||
// ── Bestellliste ──
|
||||
const listEl = document.getElementById("verwaltung-order-list");
|
||||
const emptyEl = document.getElementById("verwaltung-empty");
|
||||
if (alleBestellungen.length === 0) {
|
||||
listEl.innerHTML = "";
|
||||
emptyEl.style.display = "";
|
||||
} else {
|
||||
emptyEl.style.display = "none";
|
||||
listEl.innerHTML = alleBestellungen.map((b) => {
|
||||
const land = b.land;
|
||||
const artikelHtml = b.artikel.map((a) =>
|
||||
`<li><span>${a.menge}× ${escapeHtml(a.name)}${a.gratis ? " <span class=\"small\">(gratis)</span>" : ""}</span><span class="order-item-price">${formatPrice(a.preis * a.menge, lang)}</span></li>`
|
||||
).join("");
|
||||
const datum = new Date(b.created_at).toLocaleDateString("de-DE", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||
return `
|
||||
<div class="card order-card order-card-${b.status}" data-bucket="${bucket(b.status)}" data-order-id="${b.id}">
|
||||
<div class="order-card-head">
|
||||
<div>
|
||||
<span class="order-no">${LAND_FLAGGE[land] || ""} ${escapeHtml(b.bestellnummer || `#${b.id}`)}</span>
|
||||
<span class="small order-meta">${datum} · ${LAND_NAME[land] || land} · ${escapeHtml(b.kunde_name)} · ${escapeHtml(b.kunde_email)}</span>
|
||||
</div>
|
||||
<span class="badge order-status order-status-${b.status}">${STATUS_LABEL[b.status] || b.status}</span>
|
||||
</div>
|
||||
<ul class="order-items">${artikelHtml}</ul>
|
||||
<div class="order-card-foot verwaltung-order-foot">
|
||||
<span class="order-total">Summe: <strong>${formatPrice(Number(b.summe), lang)}</strong></span>
|
||||
<div class="verwaltung-status-edit">
|
||||
<select class="verwaltung-status-select" data-order-id="${b.id}">
|
||||
${Object.entries(STATUS_LABEL).map(([val, label]) => `<option value="${val}" ${val === b.status ? "selected" : ""}>${label}</option>`).join("")}
|
||||
</select>
|
||||
<span class="small verwaltung-status-saved" data-order-id="${b.id}" style="display:none;">✅ gespeichert</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
listEl.querySelectorAll(".verwaltung-status-select").forEach((select) => {
|
||||
select.addEventListener("change", () => statusAendern(select));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Auswertung: meistverkaufte Produkte je Kategorie (stornierte Bestellungen ausgeschlossen) ──
|
||||
const mengeProSlug = new Map(); // slug -> { name, kategorie, menge }
|
||||
aktive.forEach((b) => b.artikel.forEach((a) => {
|
||||
const eintrag = mengeProSlug.get(a.produkt_slug) ?? { name: a.name, kategorie: a.kategorie_slug, menge: 0 };
|
||||
eintrag.menge += a.menge;
|
||||
mengeProSlug.set(a.produkt_slug, eintrag);
|
||||
}));
|
||||
const kategorienMitProdukten = new Map();
|
||||
mengeProSlug.forEach((eintrag, slug) => {
|
||||
if (!eintrag.kategorie) return;
|
||||
const liste = kategorienMitProdukten.get(eintrag.kategorie) ?? [];
|
||||
liste.push({ slug, name: eintrag.name, menge: eintrag.menge });
|
||||
kategorienMitProdukten.set(eintrag.kategorie, liste);
|
||||
});
|
||||
const auswertungKategorien = Array.from(kategorienMitProdukten.entries())
|
||||
.map(([kategorieSlug, produkte]) => {
|
||||
const kat = getCategory(kategorieSlug);
|
||||
const sortiert = produkte.sort((a, b) => b.menge - a.menge);
|
||||
const maxMenge = sortiert[0]?.menge ?? 1;
|
||||
return {
|
||||
slug: kategorieSlug,
|
||||
name: kat?.name?.[lang] ?? kategorieSlug,
|
||||
icon: kat?.icon ?? "📦",
|
||||
gesamtMenge: sortiert.reduce((s, p) => s + p.menge, 0),
|
||||
produkte: sortiert.slice(0, 4).map((p) => ({ ...p, prozent: Math.round((p.menge / maxMenge) * 100) })),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.gesamtMenge - a.gesamtMenge);
|
||||
|
||||
const katGrid = document.getElementById("verwaltung-kat-grid");
|
||||
const katEmpty = document.getElementById("verwaltung-kat-empty");
|
||||
if (auswertungKategorien.length === 0) {
|
||||
katGrid.innerHTML = "";
|
||||
katEmpty.style.display = "";
|
||||
} else {
|
||||
katEmpty.style.display = "none";
|
||||
katGrid.innerHTML = auswertungKategorien.map((k) => `
|
||||
<div class="card verwaltung-kat-card">
|
||||
<h3>${k.icon} ${escapeHtml(k.name)}</h3>
|
||||
<div class="verwaltung-bar-list">
|
||||
${k.produkte.map((p) => `
|
||||
<div class="verwaltung-bar-row">
|
||||
<div class="verwaltung-bar-head"><span>${escapeHtml(p.name)}</span><span class="small">${p.menge}× verkauft</span></div>
|
||||
<div class="verwaltung-bar-track"><div class="verwaltung-bar-fill" style="width:${p.prozent}%"></div></div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
// ── Auswertung: Bestellungen nach Land (stornierte ausgeschlossen) ──
|
||||
const landCounts = new Map();
|
||||
aktive.forEach((b) => landCounts.set(b.land, (landCounts.get(b.land) ?? 0) + 1));
|
||||
const maxLandCount = Math.max(1, ...Array.from(landCounts.values()));
|
||||
const auswertungLaender = Array.from(landCounts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([land, count]) => ({
|
||||
land, name: LAND_NAME[land] || land, flagge: LAND_FLAGGE[land] || "",
|
||||
count, prozent: aktive.length > 0 ? Math.round((count / aktive.length) * 100) : 0,
|
||||
balken: Math.round((count / maxLandCount) * 100),
|
||||
}));
|
||||
|
||||
const landList = document.getElementById("verwaltung-land-list");
|
||||
landList.innerHTML = auswertungLaender.length === 0
|
||||
? `<p class="small">Noch keine Bestellungen.</p>`
|
||||
: auswertungLaender.map((l) => `
|
||||
<div class="verwaltung-bar-row">
|
||||
<div class="verwaltung-bar-head"><span>${l.flagge} ${escapeHtml(l.name)}</span><span class="small">${l.count} Bestellungen · ${l.prozent}%</span></div>
|
||||
<div class="verwaltung-bar-track"><div class="verwaltung-bar-fill verwaltung-bar-fill-land" style="width:${l.balken}%"></div></div>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
anwendenFilter(document.querySelector(".verwaltung-filter-btn.is-active")?.dataset.filter || "alle");
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
async function statusAendern(select) {
|
||||
const orderId = select.dataset.orderId;
|
||||
const neuerStatus = select.value;
|
||||
select.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/api/verwaltung/orders/${orderId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: neuerStatus }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok || !data?.ok) throw new Error(data?.error || "Speichern fehlgeschlagen.");
|
||||
|
||||
// Lokal aktualisieren + alles neu rendern (Buckets/Auswertung/Filter bleiben konsistent),
|
||||
// statt einen kompletten Server-Reload zu brauchen.
|
||||
const b = alleBestellungen.find((o) => String(o.id) === String(orderId));
|
||||
if (b) b.status = neuerStatus;
|
||||
render();
|
||||
|
||||
const savedHint = document.querySelector(`.verwaltung-status-saved[data-order-id="${orderId}"]`);
|
||||
if (savedHint) {
|
||||
savedHint.style.display = "inline";
|
||||
setTimeout(() => { savedHint.style.display = "none"; }, 2000);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("⚠️ Status konnte nicht gespeichert werden: " + (err instanceof Error ? err.message : String(err)));
|
||||
select.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function anwendenFilter(filter) {
|
||||
document.querySelectorAll(".verwaltung-filter-btn").forEach((btn) => {
|
||||
btn.classList.toggle("is-active", btn.dataset.filter === filter);
|
||||
});
|
||||
cards.forEach((card) => {
|
||||
document.querySelectorAll("#verwaltung-order-list .order-card").forEach((card) => {
|
||||
card.style.display = filter === "alle" || card.dataset.bucket === filter ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
filterBtns.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const f = btn.dataset.filter;
|
||||
if (f) {
|
||||
setFilter(f);
|
||||
if (btn.classList.contains("verwaltung-stat-tile")) {
|
||||
document.getElementById("verwaltung-order-list")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
});
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-filter]");
|
||||
if (!btn) return;
|
||||
const f = btn.dataset.filter;
|
||||
if (!f) return;
|
||||
anwendenFilter(f);
|
||||
if (btn.classList.contains("verwaltung-stat-tile")) {
|
||||
document.getElementById("verwaltung-order-list")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("verwaltung-retry-btn")?.addEventListener("click", ladeUndRendere);
|
||||
|
||||
ladeUndRendere();
|
||||
</script>
|
||||
|
||||
@@ -1704,6 +1704,7 @@ a:focus-visible, button:focus-visible {
|
||||
.order-card-versandVorbereitet { --status-color: #e3b23c; }
|
||||
.order-card-versendet { --status-color: var(--c-accent); }
|
||||
.order-card-abgeschlossen { --status-color: #7ec8a0; }
|
||||
.order-card-storniert { --status-color: var(--c-sale); }
|
||||
|
||||
.order-status { display: inline-flex; align-items: center; gap: 0.4em; }
|
||||
.order-status::before { font-size: 0.95em; }
|
||||
@@ -1712,12 +1713,28 @@ a:focus-visible, button:focus-visible {
|
||||
.order-status-versandVorbereitet::before { content: "📦"; }
|
||||
.order-status-versendet::before { content: "🚚"; }
|
||||
.order-status-abgeschlossen::before { content: "✅"; }
|
||||
.order-status-storniert::before { content: "❌"; }
|
||||
|
||||
.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; }
|
||||
.order-status-versendet { background: color-mix(in srgb, var(--c-accent) 34%, transparent); border-color: color-mix(in srgb, var(--c-accent) 55%, transparent); }
|
||||
.order-status-abgeschlossen { background: rgba(126, 200, 160, 0.28); border-color: rgba(126, 200, 160, 0.5); color: #cfeee0; }
|
||||
.order-status-storniert { background: color-mix(in srgb, var(--c-sale) 30%, transparent); border-color: color-mix(in srgb, var(--c-sale) 55%, transparent); color: #ffd7de; }
|
||||
|
||||
/* Status-Dropdown direkt in der Bestellkarte (Verwaltungsseite) — sofortiges Ändern ohne
|
||||
separate Bearbeiten-Seite, mit kurzer "gespeichert"-Bestätigung statt stillem Erfolg. */
|
||||
.verwaltung-status-edit { display: flex; align-items: center; gap: 0.6rem; }
|
||||
.verwaltung-status-select {
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: var(--radius-s);
|
||||
border: 1.5px solid rgba(244, 241, 247, 0.18);
|
||||
background: var(--c-anthracite);
|
||||
color: var(--c-text);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.verwaltung-status-saved { color: #7ec8a0; font-weight: 600; white-space: nowrap; }
|
||||
.order-items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.order-items li { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.95rem; color: var(--c-text-muted); }
|
||||
.order-item-price { color: var(--c-text); white-space: nowrap; }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Cloudflare Pages Konfiguration für "Van's DIY & Bastelbedarf" — wird von
|
||||
# `wrangler pages deploy` (siehe .github/workflows/deploy.yml) automatisch erkannt und sorgt
|
||||
# dafür, dass die D1-Datenbank für das echte Bestellsystem (siehe functions/api/) bei jedem
|
||||
# Deploy korrekt an das Pages-Projekt gebunden ist, ohne das im Dashboard von Hand einstellen
|
||||
# zu müssen (weniger Fehlerquellen, alles nachvollziehbar im Git-Verlauf).
|
||||
name = "vans-diy-bastelbedarf"
|
||||
pages_build_output_dir = "dist"
|
||||
compatibility_date = "2025-01-01"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "vans-diy-bastelbedarf-db"
|
||||
database_id = "f8a87426-0226-4def-9a6c-f11ee9bd837d"
|
||||
Reference in New Issue
Block a user