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:
qcigano
2026-08-03 20:32:46 +02:00
co-authored by Claude Sonnet 5
parent 9170b9bb22
commit fb7a7b76c2
14 changed files with 1030 additions and 333 deletions
+52
View File
@@ -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
View File
@@ -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);
}
+88
View File
@@ -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" },
});
}
+3
View File
@@ -0,0 +1,3 @@
export function json(status, data) {
return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } });
}
+120
View File
@@ -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." });
}
}
+48
View File
@@ -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." });
}
}
+68
View File
@@ -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." });
}
}
+61 -5
View File
@@ -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.
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;
}
});
</script>
+71 -5
View File
@@ -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;
}
// 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. Nur eine erfolgreich abgeschickte,
// vollständig bezahlte Bestellung zählt hier; abgebrochene/nicht abgeschickte Bestellungen
// rufen diese Funktionen nie auf.
// 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;
}
});
</script>
+61 -5
View File
@@ -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.
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;
}
});
</script>
+61 -5
View File
@@ -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.
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;
}
});
</script>
+285 -187
View File
@@ -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,33 +34,47 @@ const auswertungLaender = (Array.from(landCounts.entries()) as [Land, number][])
</div>
</section>
<section class="section-tight" id="verwaltung-loading">
<div class="container">
<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">{buckets.offen}</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">{buckets.inBearbeitung}</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">{buckets.versendet}</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">{buckets.abgeschlossen}</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">{formatPrice(gesamtumsatz, lang)}</span>
<span class="verwaltung-stat-label">Gesamtumsatz ({bestellungen.length} Bestellungen)</span>
<span class="verwaltung-stat-value" id="stat-umsatz"></span>
<span class="verwaltung-stat-label" id="stat-umsatz-label">Gesamtumsatz</span>
</div>
</div>
</div>
@@ -179,55 +84,24 @@ const auswertungLaender = (Array.from(landCounts.entries()) as [Land, number][])
<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>
<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>
))}
</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>
</section>
<section class="section-tight">
<section class="section-tight" id="verwaltung-auswertung-section">
<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>
))}
</div>
<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>
</section>
@@ -235,41 +109,265 @@ const auswertungLaender = (Array.from(landCounts.entries()) as [Land, number][])
<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>
))}
</div>
<div class="verwaltung-bar-list" id="verwaltung-land-list"></div>
</div>
</div>
</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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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", () => {
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-filter]");
if (!btn) return;
const f = btn.dataset.filter;
if (f) {
setFilter(f);
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>
+17
View File
@@ -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; }
+13
View File
@@ -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"