Compare commits

...
10 Commits
Author SHA1 Message Date
qcigano b8b8ec7b7a Node.js/Express-Server als Ersatz für Cloudflare Pages Functions + D1 (läuft auf eigenem Server statt Cloudflare)
Build & Deploy / deploy (push) Waiting to run
2026-08-05 15:06:17 +02:00
qciganoandClaude Sonnet 5 53e7bb158a Fix: Produktanzahl auf Unterkategorie-Seiten jetzt direkt über der ersten
Produktkarte statt direkt unter dem Banner

Auf den anderen Seitentypen (Kategorie, Sale) stand die Zahl schon immer direkt
über dem Produkt-Grid. Auf den Unterkategorie-Seiten (Charakter-Familien wie
Glasperlenarmbänder, Kuh, Wal, ...) saß sie dagegen in einem eigenen Container
direkt unter dem Banner-Bild, mit teils viel Abstand zur ersten echten Karte
(z.B. wenn vorher noch eine Charakter-Story-Box kommt). Jetzt wird sie direkt
in der Sektion angezeigt, die auch wirklich die erste Produktkarte enthält —
in allen 4 Sprachen (de/en/fr/ch).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-04 20:26:48 +02:00
qcigano a92a8e9666 Fix: Artikel-Anzahl auf Unterkategorie-Seiten stand über statt unter dem großen Bild
Auf den drei anderen Seitentypen (Kategorie, Sale) stand die Anzahl bereits korrekt unter dem
Banner — nur auf den Unterkategorie-Seiten (z.B. /shop/modeschmuck-accessoires/
glasperlenarmbaender/) landete sie versehentlich VOR dem Bannerbild, direkt nach der Breadcrumb.
Jetzt hinter das Banner verschoben, in allen 4 Sprachen.
2026-08-04 20:00:31 +02:00
qcigano bac6ae3090 Merge branch 'main' of https://github.com/vans-diy-bastelbedarf/website 2026-08-04 19:36:47 +02:00
qcigano aa8631f95f Artikel-Anzahl jetzt auf wirklich jeder Produktlisten-Seite sichtbar (Kategorie/Unterkategorie/Sale)
Bisher zeigte nur /shop/ (die Hauptübersicht) an, wie viele Produkte gerade zu sehen sind. Auf
ausdrücklichen Wunsch jetzt auch auf: Kategorie-Seiten, Unterkategorie-Seiten (Gesamtzahl aus
Charakter-Karten + "weitere Modelle", ohne reine Platzhalterkarten) und der Sale-Seite — jeweils
in allen 4 Sprachen. Server-seitig fest berechnet (kein Filter-JS wie im Haupt-Shop nötig, da
diese Seiten keine Live-Filterung haben), gleiche Optik/Wortlaut wie im Shop-Filter
(t.shop.resultCount), neue gemeinsame CSS-Klasse .result-count-static für den Abstand vor dem
Grid. Nur sichtbar, wenn tatsächlich Produkte da sind (bei leeren Kategorien bleibt der bisherige
Hinweistext ohne zusätzliche "0 Produkte"-Zeile).

Per Build + Browsertest über alle drei Seitentypen verifiziert.
2026-08-04 19:36:00 +02:00
vansdiyundbastelbedarfandGitHub 075ac1b1a2 Create Produkt “schluesselanhaenger-glas-note-gold” +3 2026-08-04 19:15:56 +02:00
vansdiyundbastelbedarfandGitHub 5b458165fe Create Produkt “glasperlenarmband-klein-set” +1 2026-08-04 18:45:41 +02:00
vansdiyundbastelbedarfandGitHub b52a49df17 Update Produkt “glasperlenarmband-lila-accessoires” +1 2026-08-04 18:40:08 +02:00
vansdiyundbastelbedarfandGitHub e628dba24b Update Produkt “glasperlenarmband-lila-stern” 2026-08-04 18:37:35 +02:00
vansdiyundbastelbedarfandGitHub 115b6eaab8 Update Produkt “glasperlenarmband-lila-rechteck” 2026-08-04 18:37:21 +02:00
42 changed files with 1503 additions and 20 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+34
View File
@@ -0,0 +1,34 @@
# Vorlage — echte Werte kommen in eine eigene ".env"-Datei (wird nie eingecheckt/committed,
# siehe .gitignore), niemals hier direkt eintragen.
# Wo die SQLite-Datenbankdatei liegt (außerhalb des Programmcodes, siehe db.js)
DB_PATH=/var/lib/vandiy-shop/vandiy-shop.db
# Port, auf dem der Node-Server lokal lauscht (Caddy leitet von außen dorthin weiter)
PORT=4000
# Zugangscode-Schranke (ganze Seite) — selbst gewählte, zufällige Werte
SITE_ACCESS_SECRET=
SITE_ACCESS_CODE_QCIGA=
SITE_ACCESS_CODE_VANVAN=
# Zweite Schranke für /verwaltung/ (eigenes Signier-Geheimnis, geprüfter Code ist derselbe wie oben)
VERWALTUNG_SESSION_SECRET=
# Kundenkonto-Sitzungen (Google-/PayPal-Login)
CUSTOMER_SESSION_SECRET=
# Sobald der Shop für echte Kund:innen live gehen soll: auf "true" setzen
SITE_PUBLIC=false
# PayPal (Zahlung + "Log in with PayPal") — aus dem PayPal-Entwickler-Dashboard
PAYPAL_ENV=sandbox
PAYPAL_CLIENT_ID=
PAYPAL_CLIENT_SECRET=
# Google-Login — aus der Google Cloud Console
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# E-Mail-Versand (Bestellbenachrichtigungen) — aus resend.com
RESEND_API_KEY=
+44
View File
@@ -0,0 +1,44 @@
/* =====================================================================
db.js — SQLite-Datenbank (better-sqlite3), Ersatz für Cloudflare D1.
D1 ist selbst schon SQLite unter der Haube — deshalb funktioniert das bestehende Schema
(../d1/schema.sql) unverändert. WICHTIG: ../d1/schema.sql ist bereits der KONSOLIDIERTE
Endzustand (enthält gutschein_code, paypal_order_id, customers-Tabelle direkt) — die
migration-*.sql-Dateien daneben waren einmalige ALTER-TABLE-Schritte für die SCHON LAUFENDE
alte D1-Datenbank und dürfen auf einer frischen Datenbank NICHT nochmal ausgeführt werden
(migration-0002 würde z.B. mit "duplicate column name" fehlschlagen, weil schema.sql die
Spalte schon enthält). Deshalb hier bewusst NUR schema.sql. Echte, bestehende Bestelldaten aus
der alten D1-Datenbank werden separat importiert (siehe import-d1-daten.js), nicht über die
Migrationsdateien.
Der Datenbank-Ordner liegt außerhalb des Programmcodes (siehe DB_PATH), damit ein Update des
Servers (z.B. "git pull") niemals die echten Bestelldaten anfassen kann.
===================================================================== */
import Database from "better-sqlite3";
import { readFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
// Überschreibbar über die Umgebungsvariable DB_PATH (siehe .env) — Standard: eigener,
// vom Programmcode getrennter Datenordner.
const DB_PATH = process.env.DB_PATH || "/var/lib/vandiy-shop/vandiy-shop.db";
const D1_ORDNER = join(__dirname, "..", "d1");
mkdirSync(dirname(DB_PATH), { recursive: true });
export const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL"); // bessere Nebenläufigkeit bei gleichzeitigen Lese-/Schreibzugriffen
db.pragma("foreign_keys = ON");
function fuehreSqlDateiAus(pfad) {
if (!existsSync(pfad)) return;
const sql = readFileSync(pfad, "utf-8");
db.exec(sql);
}
export function initDb() {
fuehreSqlDateiAus(join(D1_ORDNER, "schema.sql"));
}
+68
View File
@@ -0,0 +1,68 @@
/* =====================================================================
index.js — Haupteinstiegspunkt. Baut den kompletten Express-Server auf: liefert die fertig
gebaute Astro-Seite (../dist/) aus UND die API-Routen, genau wie Cloudflare Pages beides in
einem Deployment vereint hat. Die Zugangscode-Schranke (middleware/gate.js) läuft vor JEDER
Anfrage, exakt wie functions/_middleware.js das vorher bei Cloudflare tat.
Umgebungsvariablen kommen aus einer .env-Datei — gestartet wird dieser Server mit
"node --env-file=.env index.js" (Node 22 kann .env-Dateien nativ laden, kein zusätzliches
Paket nötig).
===================================================================== */
import express from "express";
import cookieParser from "cookie-parser";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync } from "node:fs";
import { initDb } from "./db.js";
import { gateMiddleware } from "./middleware/gate.js";
import { ordersRouter } from "./routes/orders.js";
import { bestsellerRouter } from "./routes/bestseller.js";
import { paypalRouter } from "./routes/paypal.js";
import { authRouter } from "./routes/auth.js";
import { accountRouter } from "./routes/account.js";
import { verwaltungRouter } from "./routes/verwaltung.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const DIST_DIR = join(__dirname, "..", "dist");
const PORT = Number(process.env.PORT || 4000);
initDb();
const app = express();
// Läuft hinter Caddy (Reverse Proxy) — nötig, damit req.protocol/req.get("host") und
// "secure"-Cookies korrekt funktionieren, statt anzunehmen, die Verbindung sei unverschlüsselt.
app.set("trust proxy", 1);
app.use(cookieParser());
app.use(express.json({ limit: "2mb" }));
// Zugangscode-Schranke vor allem anderen — exakt wie functions/_middleware.js bei Cloudflare.
app.use(gateMiddleware);
app.use(ordersRouter);
app.use(bestsellerRouter);
app.use(paypalRouter);
app.use(authRouter);
app.use(accountRouter);
app.use(verwaltungRouter);
// Statische Auslieferung der fertig gebauten Astro-Seite (nach "npm run build" in ../).
if (existsSync(DIST_DIR)) {
app.use(express.static(DIST_DIR, { extensions: ["html"] }));
} else {
console.warn(`⚠️ ${DIST_DIR} existiert nicht — "npm run build" im Astro-Projekt (../) noch nicht ausgeführt.`);
}
// 404 für alles, was weder Datei noch bekannte Route ist.
app.use((req, res) => {
res.status(404).sendFile(join(DIST_DIR, "404.html"), (err) => {
if (err) res.status(404).send("Nicht gefunden.");
});
});
app.listen(PORT, "127.0.0.1", () => {
console.log(`Van's DIY & Bastelbedarf Server läuft auf http://127.0.0.1:${PORT}`);
});
+76
View File
@@ -0,0 +1,76 @@
/* =====================================================================
lib/auth.js — gemeinsame Session-Logik für die Zugangscode-Schranke (qciga/VanVan).
1:1 portiert aus functions/_shared/auth.js — Node 22 hat crypto.subtle, btoa/atob global
verfügbar (Web-Crypto-Standard), deshalb konnte die Logik praktisch unverändert übernommen
werden. Einziger Unterschied: getCookie() liest jetzt aus Express' geparsten req.cookies statt
selbst den Cookie-Header zu zerlegen (cookie-parser übernimmt das).
===================================================================== */
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;
}
}
/** Liefert die Rolle ("qciga"/"vanvan") bei gültiger Sitzung, sonst null. */
export async function getGateRole(req) {
if (!process.env.SITE_ACCESS_SECRET) return null;
const token = req.cookies?.[COOKIE_NAME];
const payload = token ? await verifySession(token, process.env.SITE_ACCESS_SECRET) : null;
return payload?.role ?? null;
}
/** Standard-401-Antwort für API-Routen ohne gültige Sitzung. */
export function unauthorizedJson(res) {
return res.status(401).json({ ok: false, error: "Nicht angemeldet." });
}
+180
View File
@@ -0,0 +1,180 @@
/* =====================================================================
lib/bestellung-erstellen.js — die eigentliche "Bestellung anlegen"-Logik. 1:1 portiert aus
functions/_shared/bestellung-erstellen.js, D1-Aufrufe (async, .bind().run()) auf better-
sqlite3 (synchron) umgestellt. Gleiche Validierung, gleiche "nur einmal pro Person"-Logik,
gleicher E-Mail-Versand.
===================================================================== */
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { db } from "../db.js";
import { sendeEmail, escapeHtmlFuerEmail } from "./email.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const gutscheineRaw = JSON.parse(
readFileSync(join(__dirname, "..", "..", "src", "content", "gutscheine.json"), "utf-8")
);
const ERLAUBTE_LAENDER = ["de", "at", "ch", "lu"];
const gutscheine = gutscheineRaw?.codes || [];
const BENACHRICHTIGUNGS_EMAIL = "[email protected]";
const LAND_NAME = { de: "Deutschland", at: "Österreich", ch: "Schweiz", lu: "Luxemburg" };
function formatPreis(n) {
return `${Number(n).toFixed(2).replace(".", ",")}`;
}
function bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe, status }) {
const artikelZeilen = artikel
.map((a) => `<li>${a.menge}× ${escapeHtmlFuerEmail(a.name)}${formatPreis(a.preis * a.menge)}</li>`)
.join("");
const statusZeile =
status === "zahlungOffen"
? `<p style="color:#b06a00;"><strong>⏳ Zahlung noch ausstehend</strong> — Überweisung, bitte Zahlungseingang abwarten, bevor die Ware verschickt wird.</p>`
: `<p style="color:#1f9254;"><strong>✅ Zahlung bestätigt</strong> (PayPal)</p>`;
return `
<div style="font-family: sans-serif; color: #1a1a1a; line-height: 1.6;">
<h2 style="margin: 0 0 0.5em;">🩵 Neue Bestellung ${escapeHtmlFuerEmail(bestellnummer)}</h2>
${statusZeile}
<p><strong>Von:</strong> ${escapeHtmlFuerEmail(name)} (${escapeHtmlFuerEmail(email)})<br />
<strong>Land:</strong> ${escapeHtmlFuerEmail(LAND_NAME[land] || land)}</p>
<p><strong>Artikel:</strong></p>
<ul>${artikelZeilen}</ul>
<p><strong>Summe: ${formatPreis(summe)}</strong></p>
<p><a href="https://vans-diy-bastelbedarf.de/verwaltung/">→ Zur Bestellverwaltung</a></p>
</div>
`;
}
function findeGutschein(code) {
const normalisiert = String(code || "").trim().toUpperCase();
if (!normalisiert) return null;
return gutscheine.find((g) => String(g.code || "").toUpperCase() === normalisiert) || null;
}
/**
* Validiert die Bestelldaten, legt sie in SQLite an und verschickt die Benachrichtigungsmail.
* @param {*} body Roh-Payload vom Checkout (gleiches Format wie bisher)
* @param {{status: "zahlungOffen"|"bezahlt", paypalOrderId?: string|null}} optionen
*/
export async function erstelleBestellung(body, optionen) {
const { status, paypalOrderId = null } = optionen;
const land = String(body.land || "").toLowerCase();
if (!ERLAUBTE_LAENDER.includes(land)) {
return { ok: false, httpStatus: 400, error: "Ungültiges Land." };
}
const kunde = body.kunde && typeof body.kunde === "object" ? body.kunde : {};
const name = String(kunde.name || "").trim();
const email = String(kunde.email || "").trim();
if (!name || !email || !email.includes("@")) {
return { ok: false, httpStatus: 400, error: "Ungültige Kundendaten." };
}
const artikel = Array.isArray(body.artikel) ? body.artikel : [];
if (artikel.length === 0) {
return { ok: false, httpStatus: 400, error: "Die Bestellung enthält keine Artikel." };
}
for (const a of artikel) {
if (!a || typeof a.slug !== "string" || !a.slug.trim() || typeof a.name !== "string" || !a.name.trim()) {
return { ok: false, httpStatus: 400, error: "Ungültige Artikeldaten." };
}
if (!Number.isFinite(a.menge) || a.menge <= 0) {
return { ok: false, httpStatus: 400, error: "Ungültige Artikelmenge." };
}
if (!Number.isFinite(a.preis) || a.preis < 0) {
return { ok: false, httpStatus: 400, error: "Ungültiger Artikelpreis." };
}
}
const summe = Number(body.summe);
if (!Number.isFinite(summe) || summe < 0) {
return { ok: false, httpStatus: 400, error: "Ungültige Bestellsumme." };
}
const zwischensumme = Number.isFinite(Number(body.zwischensumme)) ? Number(body.zwischensumme) : 0;
const versandkosten = Number.isFinite(Number(body.versandkosten)) ? Number(body.versandkosten) : 0;
const rabattGesamt = Number.isFinite(Number(body.rabattGesamt)) ? Number(body.rabattGesamt) : 0;
const gutscheinCodeRoh = typeof body.gutscheinCode === "string" ? body.gutscheinCode.trim() : "";
let gutscheinCodeNormalisiert = null;
if (gutscheinCodeRoh) {
const gutschein = findeGutschein(gutscheinCodeRoh);
gutscheinCodeNormalisiert = gutscheinCodeRoh.toUpperCase();
if (gutschein && gutschein.einmaligProPerson) {
const { anzahl } = db
.prepare(
`SELECT COUNT(*) as anzahl FROM orders WHERE gutschein_code = ? AND LOWER(kunde_email) = LOWER(?) AND status != 'storniert'`
)
.get(gutscheinCodeNormalisiert, email);
if (anzahl > 0) {
return {
ok: false,
httpStatus: 400,
error: "Dieser Gutscheincode wurde bereits mit dieser E-Mail-Adresse eingelöst und gilt nur einmal pro Person.",
};
}
}
}
const now = new Date().toISOString();
try {
const insertOrder = db
.prepare(
`INSERT INTO orders
(created_at, updated_at, status, kunde_name, kunde_email, land, strasse, plz, ort,
zahlungsart, zwischensumme, versandkosten, rabatt_gesamt, summe,
treuebonus_verwendet, abo_rabatt_verwendet, gutschein_code, paypal_order_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
)
.run(
now,
now,
status,
name,
email,
land,
String(kunde.strasse || ""),
String(kunde.plz || ""),
String(kunde.ort || ""),
String(body.zahlungsart || ""),
zwischensumme,
versandkosten,
rabattGesamt,
summe,
body.treuebonusVerwendet ? 1 : 0,
body.aboRabattVerwendet ? 1 : 0,
gutscheinCodeNormalisiert,
paypalOrderId
);
const orderId = insertOrder.lastInsertRowid;
const jahr = new Date().getFullYear();
const bestellnummer = `VDB-${jahr}-${String(orderId).padStart(4, "0")}`;
db.prepare(`UPDATE orders SET bestellnummer = ? WHERE id = ?`).run(bestellnummer, orderId);
const insertItem = db.prepare(
`INSERT INTO order_items (order_id, produkt_slug, name, kategorie_slug, menge, preis, gratis)
VALUES (?,?,?,?,?,?,?)`
);
const insertAlleItems = db.transaction((items) => {
for (const a of items) {
insertItem.run(orderId, a.slug, a.name, a.kategorie || null, Math.round(a.menge), a.preis, a.gratis ? 1 : 0);
}
});
insertAlleItems(artikel);
await sendeEmail({
to: BENACHRICHTIGUNGS_EMAIL,
subject: `${status === "zahlungOffen" ? "⏳" : "🩵"} Neue Bestellung ${bestellnummer}`,
html: bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe, status }),
text: `Neue Bestellung ${bestellnummer} von ${name} (${email}), ${artikel.length} Artikel, Summe ${formatPreis(summe)}. Status: ${status === "zahlungOffen" ? "Zahlung ausstehend (Überweisung)" : "Bezahlt (PayPal)"}.`,
});
return { ok: true, id: orderId, bestellnummer };
} catch (err) {
return { ok: false, httpStatus: 500, error: "Bestellung konnte nicht gespeichert werden." };
}
}
+74
View File
@@ -0,0 +1,74 @@
/* =====================================================================
lib/customer-auth.js — Sitzungs-Logik für echte Kundenkonten (Google-/PayPal-Login).
Bewusst GETRENNT von lib/auth.js (Zugangscode-Schranke) — eigenes Geheimnis, eigenes Cookie,
1:1 Prinzip wie im Original (functions/_shared/customer-auth.js). Einziger Unterschied:
getCustomer() fragt jetzt synchron better-sqlite3 statt async D1 ab.
===================================================================== */
import { db } from "../db.js";
export const CUSTOMER_COOKIE_NAME = "vandiy_customer_session";
export const CUSTOMER_SESSION_TAGE = 30;
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 signCustomerSession(customerId, secret) {
const payload = JSON.stringify({ cid: customerId, exp: Date.now() + CUSTOMER_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));
return `${payloadB64}.${b64urlEncode(new Uint8Array(sig))}`;
}
export async function verifyCustomerSession(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; // { cid, exp }
} catch {
return null;
}
}
/** Liefert den eingeloggten customers-Datensatz für die aktuelle Anfrage, oder null. Kunden-ID
* kommt NIE direkt vom Client, immer nur aus der signierten, serverseitig geprüften Sitzung. */
export async function getCustomer(req) {
if (!process.env.CUSTOMER_SESSION_SECRET) return null;
const token = req.cookies?.[CUSTOMER_COOKIE_NAME];
const payload = token ? await verifyCustomerSession(token, process.env.CUSTOMER_SESSION_SECRET) : null;
if (!payload?.cid) return null;
const row = db.prepare(`SELECT id, email, name, provider, created_at FROM customers WHERE id = ?`).get(payload.cid);
return row || null;
}
export function setCustomerCookie(res, token, maxAgeSekunden) {
res.cookie(CUSTOMER_COOKIE_NAME, token, {
path: "/",
maxAge: maxAgeSekunden * 1000,
httpOnly: true,
secure: true,
sameSite: "lax",
});
}
export function clearCustomerCookie(res) {
res.clearCookie(CUSTOMER_COOKIE_NAME, { path: "/" });
}
+41
View File
@@ -0,0 +1,41 @@
/* =====================================================================
lib/email.js — E-Mail-Versand über Resend. 1:1 portiert aus functions/_shared/email.js.
sendeEmail() wirft NIE einen Fehler, gibt nur { ok, error? } zurück — eine fehlgeschlagene
E-Mail darf nie eine Bestellung zum Scheitern bringen (siehe bestellung-erstellen.js).
===================================================================== */
const RESEND_API_URL = "https://api.resend.com/emails";
const STANDARD_ABSENDER = "Van's DIY & Bastelbedarf <[email protected]>";
export async function sendeEmail({ to, subject, html, text, from }) {
if (!process.env.RESEND_API_KEY) {
return { ok: false, error: "RESEND_API_KEY nicht gesetzt — E-Mail-Versand noch nicht eingerichtet." };
}
try {
const res = await fetch(RESEND_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: from || process.env.RESEND_FROM || STANDARD_ABSENDER,
to: Array.isArray(to) ? to : [to],
subject,
html,
text,
}),
});
if (!res.ok) {
const fehlertext = await res.text().catch(() => "");
return { ok: false, error: `Resend antwortete mit Status ${res.status}: ${fehlertext}` };
}
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export function escapeHtmlFuerEmail(str) {
return String(str ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
+119
View File
@@ -0,0 +1,119 @@
/* =====================================================================
lib/oauth-handlers.js — gemeinsame Start-/Callback-Logik für alle OAuth-Anbieter. 1:1 portiert
aus functions/_shared/oauth-handlers.js, auf Express (req/res) statt Cloudflare
Request/Response umgestellt.
===================================================================== */
import {
erzeugeZufallswert,
codeChallengeFuer,
baueAutorisierungsUrl,
istProviderKonfiguriert,
tauscheCodeGegenToken,
holeNutzerprofil,
} from "./oauth.js";
import { signCustomerSession, setCustomerCookie, CUSTOMER_SESSION_TAGE } from "./customer-auth.js";
import { db } from "../db.js";
const STATE_COOKIE = "vandiy_oauth_state";
const VERIFIER_COOKIE = "vandiy_oauth_verifier";
const LANG_COOKIE = "vandiy_oauth_lang";
const OAUTH_TEMP_MAXAGE = 600; // Sekunden
const ERLAUBTE_SPRACHEN = ["", "en", "ch", "fr"];
function langPrefix(lang) {
return ERLAUBTE_SPRACHEN.includes(lang) ? lang : "";
}
function originVon(req) {
return `${req.protocol}://${req.get("host")}`;
}
/** GET /api/auth/:provider/start */
export async function starteOAuth(req, res, providerName) {
const origin = originVon(req);
const lang = langPrefix(req.query.lang || "");
if (!istProviderKonfiguriert(providerName)) {
return res.redirect(302, `${origin}/${lang ? lang + "/" : ""}konto/?login_error=${providerName}_unconfigured`);
}
const state = erzeugeZufallswert(24);
const codeVerifier = erzeugeZufallswert(48);
const codeChallenge = await codeChallengeFuer(codeVerifier);
const redirectUri = `${origin}/api/auth/${providerName}/callback`;
const authUrl = baueAutorisierungsUrl(providerName, { redirectUri, state, codeChallenge });
const cookiePath = `/api/auth/${providerName}`;
const cookieOpts = { path: cookiePath, maxAge: OAUTH_TEMP_MAXAGE * 1000, httpOnly: true, secure: true, sameSite: "lax" };
res.cookie(STATE_COOKIE, state, cookieOpts);
res.cookie(VERIFIER_COOKIE, codeVerifier, cookieOpts);
res.cookie(LANG_COOKIE, lang, cookieOpts);
return res.redirect(302, authUrl);
}
/** GET /api/auth/:provider/callback */
export async function verarbeiteOAuthCallback(req, res, providerName) {
const origin = originVon(req);
const code = req.query.code;
const state = req.query.state;
const errorParam = req.query.error;
const lang = langPrefix(req.cookies?.[LANG_COOKIE] || "");
const kontoPfad = `${origin}/${lang ? lang + "/" : ""}konto/`;
const cookiePath = `/api/auth/${providerName}`;
function loescheTempCookies() {
res.clearCookie(STATE_COOKIE, { path: cookiePath });
res.clearCookie(VERIFIER_COOKIE, { path: cookiePath });
res.clearCookie(LANG_COOKIE, { path: cookiePath });
}
function fehlerRedirect(grund) {
loescheTempCookies();
return res.redirect(302, `${kontoPfad}?login_error=${grund}`);
}
if (errorParam) return fehlerRedirect(`${providerName}_denied`);
if (!code || !state) return fehlerRedirect(`${providerName}_invalid`);
const stateCookie = req.cookies?.[STATE_COOKIE];
const verifierCookie = req.cookies?.[VERIFIER_COOKIE];
if (!stateCookie || stateCookie !== state || !verifierCookie) {
return fehlerRedirect(`${providerName}_state_mismatch`);
}
if (!process.env.CUSTOMER_SESSION_SECRET) {
return fehlerRedirect(`${providerName}_backend_unconfigured`);
}
try {
const redirectUri = `${origin}/api/auth/${providerName}/callback`;
const accessToken = await tauscheCodeGegenToken(providerName, { code, redirectUri, codeVerifier: verifierCookie });
const profil = await holeNutzerprofil(providerName, accessToken);
const now = new Date().toISOString();
const bestehend = db
.prepare(`SELECT id FROM customers WHERE provider = ? AND provider_user_id = ?`)
.get(providerName, profil.providerUserId);
let customerId;
if (bestehend) {
customerId = bestehend.id;
db.prepare(`UPDATE customers SET email = ?, name = ?, updated_at = ?, last_login_at = ? WHERE id = ?`)
.run(profil.email, profil.name, now, now, customerId);
} else {
const insert = db
.prepare(
`INSERT INTO customers (created_at, updated_at, last_login_at, provider, provider_user_id, email, name) VALUES (?,?,?,?,?,?,?)`
)
.run(now, now, now, providerName, profil.providerUserId, profil.email, profil.name);
customerId = insert.lastInsertRowid;
}
const sessionToken = await signCustomerSession(customerId, process.env.CUSTOMER_SESSION_SECRET);
setCustomerCookie(res, sessionToken, CUSTOMER_SESSION_TAGE * 24 * 60 * 60);
loescheTempCookies();
return res.redirect(302, `${origin}/${lang ? lang + "/" : ""}konto/angemeldet/`);
} catch (err) {
return fehlerRedirect(`${providerName}_failed`);
}
}
+120
View File
@@ -0,0 +1,120 @@
/* =====================================================================
lib/oauth.js — echte OAuth-/OpenID-Connect-Anmeldung für Google und "Log in with PayPal".
1:1 portiert aus functions/_shared/oauth.js, Zugangsdaten aus process.env statt env.*.
===================================================================== */
function paypalBasis() {
return process.env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
}
function paypalSigninBasis() {
return process.env.PAYPAL_ENV === "live" ? "https://www.paypal.com" : "https://www.sandbox.paypal.com";
}
const PROVIDERS = {
google: {
authorizeUrl: () => "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: () => "https://oauth2.googleapis.com/token",
userinfoUrl: () => "https://www.googleapis.com/oauth2/v3/userinfo",
scope: "openid email profile",
clientIdEnvKey: "GOOGLE_CLIENT_ID",
clientSecretEnvKey: "GOOGLE_CLIENT_SECRET",
tokenAuthStyle: "body",
parseUserinfo: (data) => ({ providerUserId: data.sub, email: data.email, name: data.name || null }),
},
paypal: {
authorizeUrl: () => `${paypalSigninBasis()}/signin/authorize`,
tokenUrl: () => `${paypalBasis()}/v1/oauth2/token`,
userinfoUrl: () => `${paypalBasis()}/v1/identity/openidconnect/userinfo/?schema=openid`,
scope: "openid email profile",
clientIdEnvKey: "PAYPAL_CLIENT_ID",
clientSecretEnvKey: "PAYPAL_CLIENT_SECRET",
tokenAuthStyle: "basic",
parseUserinfo: (data) => ({ providerUserId: data.user_id || data.payer_id, email: data.email, name: data.name || null }),
},
};
export function provider(name) {
const p = PROVIDERS[name];
if (!p) throw new Error(`Unbekannter OAuth-Anbieter: ${name}`);
return p;
}
export function istProviderKonfiguriert(name) {
const p = provider(name);
return !!(process.env[p.clientIdEnvKey] && process.env[p.clientSecretEnvKey]);
}
export function baueAutorisierungsUrl(name, { redirectUri, state, codeChallenge }) {
const p = provider(name);
const clientId = process.env[p.clientIdEnvKey];
const params = new URLSearchParams({
client_id: clientId,
response_type: "code",
scope: p.scope,
redirect_uri: redirectUri,
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
if (name === "google") params.set("prompt", "select_account");
return `${p.authorizeUrl()}?${params.toString()}`;
}
export async function tauscheCodeGegenToken(name, { code, redirectUri, codeVerifier }) {
const p = provider(name);
const clientId = process.env[p.clientIdEnvKey];
const clientSecret = process.env[p.clientSecretEnvKey];
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
});
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
if (p.tokenAuthStyle === "basic") {
headers.Authorization = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`;
} else {
body.set("client_id", clientId);
body.set("client_secret", clientSecret);
}
const res = await fetch(p.tokenUrl(), { method: "POST", headers, body: body.toString() });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Token-Austausch bei ${name} fehlgeschlagen (Status ${res.status}): ${text}`);
}
const data = await res.json();
if (!data.access_token) throw new Error(`${name} hat kein Zugangstoken geliefert.`);
return data.access_token;
}
export async function holeNutzerprofil(name, accessToken) {
const p = provider(name);
const res = await fetch(p.userinfoUrl(), { headers: { Authorization: `Bearer ${accessToken}` } });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Profilabruf bei ${name} fehlgeschlagen (Status ${res.status}): ${text}`);
}
const data = await res.json();
const profil = p.parseUserinfo(data);
if (!profil.providerUserId || !profil.email) {
throw new Error(`${name} hat kein vollständiges Profil geliefert (E-Mail/ID fehlt).`);
}
return profil;
}
function b64url(bytes) {
let bin = "";
bytes.forEach((b) => (bin += String.fromCharCode(b)));
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export function erzeugeZufallswert(laenge = 32) {
const bytes = new Uint8Array(laenge);
crypto.getRandomValues(bytes);
return b64url(bytes);
}
export async function codeChallengeFuer(codeVerifier) {
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
return b64url(new Uint8Array(hash));
}
+69
View File
@@ -0,0 +1,69 @@
/* =====================================================================
lib/paypal.js — echte, server-seitige PayPal-Anbindung (REST API v2). 1:1 portiert aus
functions/_shared/paypal.js (reines fetch()-basiertes Modul, keine Cloudflare-Spezifika,
deshalb unverändert übernehmbar). Zugangsdaten kommen aus process.env statt env.*.
===================================================================== */
function paypalBasisUrl() {
return process.env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
}
export function paypalKonfiguriert() {
return !!(process.env.PAYPAL_CLIENT_ID && process.env.PAYPAL_CLIENT_SECRET);
}
async function holeZugangstoken() {
const res = await fetch(`${paypalBasisUrl()}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`PayPal-Zugangstoken konnte nicht geholt werden (Status ${res.status}): ${text}`);
}
const data = await res.json();
return data.access_token;
}
export async function erstellePaypalBestellung(betragEuro) {
const token = await holeZugangstoken();
const res = await fetch(`${paypalBasisUrl()}/v2/checkout/orders`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
intent: "CAPTURE",
purchase_units: [{ amount: { currency_code: "EUR", value: Math.max(0.01, betragEuro).toFixed(2) } }],
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`PayPal-Bestellung konnte nicht angelegt werden (Status ${res.status}): ${text}`);
}
const data = await res.json();
return data.id;
}
export async function erfassePaypalZahlung(paypalOrderId) {
try {
const token = await holeZugangstoken();
const res = await fetch(`${paypalBasisUrl()}/v2/checkout/orders/${encodeURIComponent(paypalOrderId)}/capture`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
});
const data = await res.json().catch(() => null);
if (!res.ok || !data) {
return { ok: false, error: `PayPal-Zahlung konnte nicht erfasst werden (Status ${res.status}).` };
}
const capture = data?.purchase_units?.[0]?.payments?.captures?.[0];
if (data.status !== "COMPLETED" || !capture || capture.status !== "COMPLETED") {
return { ok: false, error: `PayPal-Zahlung ist nicht abgeschlossen (Status: ${data.status}).` };
}
return { ok: true, captureId: capture.id, betragEuro: Number(capture.amount?.value ?? 0) };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
+54
View File
@@ -0,0 +1,54 @@
/* =====================================================================
lib/verwaltung-auth.js — ZWEITE, ZUSÄTZLICHE Zugangsschranke NUR für /verwaltung/ +
/api/verwaltung/*, unabhängig vom normalen 90-Tage-Seitenzugang (lib/auth.js). 24h-Sitzung,
geprüfter Code ist derselbe wie beim normalen Zugang (siehe middleware/gate.js). 1:1 portiert
aus functions/_shared/verwaltung-auth.js.
===================================================================== */
export const VERWALTUNG_COOKIE_NAME = "vandiy_verwaltung_session";
export const VERWALTUNG_SESSION_STUNDEN = 24;
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 signVerwaltungSession(secret) {
const payload = JSON.stringify({ exp: Date.now() + VERWALTUNG_SESSION_STUNDEN * 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));
return `${payloadB64}.${b64urlEncode(new Uint8Array(sig))}`;
}
export async function verifyVerwaltungSession(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;
} catch {
return null;
}
}
export async function hatGueltigeVerwaltungSitzung(req) {
if (!process.env.VERWALTUNG_SESSION_SECRET) return false;
const token = req.cookies?.[VERWALTUNG_COOKIE_NAME];
const payload = token ? await verifyVerwaltungSession(token, process.env.VERWALTUNG_SESSION_SECRET) : null;
return !!payload;
}
+128
View File
@@ -0,0 +1,128 @@
/* =====================================================================
middleware/gate.js — Zugangs-Schranke vor der ganzen Website. 1:1 portiert aus
functions/_middleware.js (onRequest), auf Express-Middleware-Stil umgestellt (next() statt
context.next(), req/res statt Request/Response). Gleiche Regeln, gleiche Reihenfolge:
- /admin/* nie anfassen (eigener, separater Worker mit GitHub-OAuth).
- /gate-logout immer erreichbar.
- Rechtstexte (Impressum, Datenschutz, AGB, Widerruf) immer ohne Code erreichbar.
- Fail-open, falls die Zugangs-Secrets fehlen (lieber offen als sich selbst aussperren).
- /verwaltung/* + /api/verwaltung/* bleiben IMMER geschützt, auch wenn SITE_PUBLIC=true.
- SITE_PUBLIC=true schaltet den Rest der Seite öffentlich (außer /verwaltung).
- API-Routen bekommen bei fehlender Sitzung 401-JSON statt HTML-Redirect.
===================================================================== */
import { COOKIE_NAME, ROLE_COOKIE_NAME, SESSION_TAGE, signSession, verifySession } from "../lib/auth.js";
import {
VERWALTUNG_COOKIE_NAME,
VERWALTUNG_SESSION_STUNDEN,
signVerwaltungSession,
verifyVerwaltungSession,
} from "../lib/verwaltung-auth.js";
const OEFFENTLICHE_SEITEN = new Set(["/impressum", "/datenschutz", "/agb", "/widerruf", "/muster-widerrufsformular"]);
function istOeffentlichErreichbar(pfad) {
const ohneSlash = pfad.endsWith("/") && pfad.length > 1 ? pfad.slice(0, -1) : pfad;
if (OEFFENTLICHE_SEITEN.has(ohneSlash)) return true;
if (pfad.startsWith("/_astro/") && pfad.endsWith(".css")) return true;
if (pfad === "/img/gate-avatar-dogi.webp" || pfad === "/img/gate-avatar-vanvan.webp") return true;
if (/^\/(logo\.png|favicon\.ico|favicon-\d+x\d+\.png|apple-touch-icon\.png|icon-\d+(-maskable)?\.png|sw\.js|manifest\.webmanifest)$/.test(pfad)) return true;
return false;
}
export async function gateAuthHandler(req, res) {
const code = (req.body?.code || "").trim();
const next = typeof req.body?.next === "string" && req.body.next.startsWith("/") ? req.body.next : "/";
let role = null;
if (process.env.SITE_ACCESS_CODE_QCIGA && code === process.env.SITE_ACCESS_CODE_QCIGA) role = "qciga";
else if (process.env.SITE_ACCESS_CODE_VANVAN && code === process.env.SITE_ACCESS_CODE_VANVAN) role = "vanvan";
if (!role) {
return res.status(401).json({ ok: false, error: "Falscher Zugangscode." });
}
const session = await signSession(role, process.env.SITE_ACCESS_SECRET);
const maxAge = SESSION_TAGE * 24 * 60 * 60 * 1000;
res.cookie(COOKIE_NAME, session, { path: "/", maxAge, httpOnly: true, secure: true, sameSite: "lax" });
res.cookie(ROLE_COOKIE_NAME, role, { path: "/", maxAge, secure: true, sameSite: "lax" });
return res.json({ ok: true, next });
}
export async function verwaltungAuthHandler(req, res) {
const code = (req.body?.code || "").trim();
const next = typeof req.body?.next === "string" && req.body.next.startsWith("/") ? req.body.next : "/verwaltung/";
const codeGueltig =
(process.env.SITE_ACCESS_CODE_QCIGA && code === process.env.SITE_ACCESS_CODE_QCIGA) ||
(process.env.SITE_ACCESS_CODE_VANVAN && code === process.env.SITE_ACCESS_CODE_VANVAN);
if (!codeGueltig) {
return res.status(401).json({ ok: false, error: "Falscher Code." });
}
const session = await signVerwaltungSession(process.env.VERWALTUNG_SESSION_SECRET);
const maxAge = VERWALTUNG_SESSION_STUNDEN * 60 * 60 * 1000;
res.cookie(VERWALTUNG_COOKIE_NAME, session, { path: "/", maxAge, httpOnly: true, secure: true, sameSite: "lax" });
return res.json({ ok: true, next });
}
export function logoutHandler(req, res) {
res.clearCookie(COOKIE_NAME, { path: "/" });
res.clearCookie(ROLE_COOKIE_NAME, { path: "/" });
res.clearCookie(VERWALTUNG_COOKIE_NAME, { path: "/" });
return res.json({ ok: true });
}
/** Die eigentliche Middleware, vor JEDER Anfrage montiert (siehe index.js). */
export function gateMiddleware(req, res, next) {
const pfad = req.path;
if (pfad.startsWith("/admin")) return next();
if (pfad === "/gate-logout" && req.method === "POST") return logoutHandler(req, res);
if (istOeffentlichErreichbar(pfad)) return next();
const codesKonfiguriert = !!(process.env.SITE_ACCESS_SECRET && (process.env.SITE_ACCESS_CODE_QCIGA || process.env.SITE_ACCESS_CODE_VANVAN));
if (!codesKonfiguriert) return next();
const immerGeschuetzterPfad = pfad.startsWith("/verwaltung") || pfad.startsWith("/api/verwaltung");
const sitePublic = process.env.SITE_PUBLIC === "true";
if (sitePublic && !immerGeschuetzterPfad) return next();
if (pfad === "/gate-auth" && req.method === "POST") return gateAuthHandler(req, res);
const sessionToken = req.cookies?.[COOKIE_NAME];
(async () => {
const payload = sessionToken ? await verifySession(sessionToken, process.env.SITE_ACCESS_SECRET) : null;
if (payload) {
if (!immerGeschuetzterPfad) return next();
if (pfad === "/verwaltung-code.html" || pfad === "/verwaltung-code") return next();
if (pfad === "/verwaltung-auth" && req.method === "POST") return verwaltungAuthHandler(req, res);
if (!process.env.VERWALTUNG_SESSION_SECRET) return next();
const vwToken = req.cookies?.[VERWALTUNG_COOKIE_NAME];
const vwPayload = vwToken ? await verifyVerwaltungSession(vwToken, process.env.VERWALTUNG_SESSION_SECRET) : null;
if (vwPayload) return next();
if (pfad.startsWith("/api/")) {
return res.status(401).json({ ok: false, error: "Zusätzlicher Verwaltungs-Code erforderlich." });
}
const vwNext = pfad + (req.originalUrl.includes("?") ? "?" + req.originalUrl.split("?")[1] : "");
return res.redirect(302, `/verwaltung-code.html?next=${encodeURIComponent(vwNext)}`);
}
if (pfad === "/gate.html" || pfad === "/gate") return next();
if (pfad.startsWith("/api/")) {
return res.status(401).json({ ok: false, error: "Nicht angemeldet." });
}
const nextParam = pfad + (req.originalUrl.includes("?") ? "?" + req.originalUrl.split("?")[1] : "");
return res.redirect(302, `/gate?next=${encodeURIComponent(nextParam)}`);
})();
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "vandiy-shop-server",
"type": "module",
"version": "1.0.0",
"description": "Eigener Node.js/Express-Server für Van's DIY & Bastelbedarf Ersatz für Cloudflare Pages Functions + D1, läuft auf dem eigenen Netcup-Server. Portiert 1:1 aus functions/ (Cloudflare Pages Functions), gleiche Sicherheitslogik, gleiche Datenbankstruktur (SQLite statt D1).",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"start": "node index.js"
},
"dependencies": {
"better-sqlite3": "^11.8.1",
"cookie-parser": "^1.4.7",
"express": "^4.21.2"
}
}
+54
View File
@@ -0,0 +1,54 @@
/* =====================================================================
routes/account.js — Kundenkonto-Selbstbedienung: me, logout, delete (DSGVO Art. 17),
export (DSGVO Art. 15/20). 1:1 portiert aus functions/api/account/*.js.
===================================================================== */
import { Router } from "express";
import { getCustomer, clearCustomerCookie } from "../lib/customer-auth.js";
import { db } from "../db.js";
export const accountRouter = Router();
accountRouter.get("/api/account/me", async (req, res) => {
const customer = await getCustomer(req);
if (!customer) return res.json({ ok: false });
return res.json({
ok: true,
customer: { email: customer.email, name: customer.name, provider: customer.provider, seit: customer.created_at },
});
});
accountRouter.post("/api/account/logout", (req, res) => {
clearCustomerCookie(res);
return res.json({ ok: true });
});
accountRouter.post("/api/account/delete", async (req, res) => {
const customer = await getCustomer(req);
if (!customer) return res.status(401).json({ ok: false, error: "Nicht angemeldet." });
try {
db.prepare(`DELETE FROM customers WHERE id = ?`).run(customer.id);
} catch {
return res.status(500).json({ ok: false, error: "Konto konnte nicht gelöscht werden. Bitte versuche es erneut." });
}
clearCustomerCookie(res);
return res.json({ ok: true });
});
accountRouter.get("/api/account/export", async (req, res) => {
const customer = await getCustomer(req);
if (!customer) return res.status(401).json({ ok: false, error: "Nicht angemeldet." });
const daten = {
konto: { email: customer.email, name: customer.name, anmeldeart: customer.provider, erstelltAm: customer.created_at },
hinweis:
"Dies sind alle Daten, die Van's DIY & Bastelbedarf zu deinem Kundenkonto speichert. " +
"Bereits aufgegebene Bestellungen werden getrennt davon aus gesetzlichen Aufbewahrungsgründen " +
"weiter gespeichert (siehe Datenschutzerklärung, Abschnitt Bestellabwicklung).",
};
res.setHeader("Content-Disposition", 'attachment; filename="meine-daten.json"');
return res.status(200).json(daten);
});
+15
View File
@@ -0,0 +1,15 @@
/* =====================================================================
routes/auth.js — OAuth-Start/Callback für Google + PayPal-Login. 1:1 portiert aus
functions/api/auth/google/{start,callback}.js + functions/api/auth/paypal/{start,callback}.js
(beide Anbieter nutzen dieselbe gemeinsame Logik, siehe lib/oauth-handlers.js).
===================================================================== */
import { Router } from "express";
import { starteOAuth, verarbeiteOAuthCallback } from "../lib/oauth-handlers.js";
export const authRouter = Router();
authRouter.get("/api/auth/google/start", (req, res) => starteOAuth(req, res, "google"));
authRouter.get("/api/auth/google/callback", (req, res) => verarbeiteOAuthCallback(req, res, "google"));
authRouter.get("/api/auth/paypal/start", (req, res) => starteOAuth(req, res, "paypal"));
authRouter.get("/api/auth/paypal/callback", (req, res) => verarbeiteOAuthCallback(req, res, "paypal"));
+28
View File
@@ -0,0 +1,28 @@
/* =====================================================================
routes/bestseller.js — GET /api/bestseller. 1:1 portiert aus functions/api/bestseller.js,
D1-Abfrage auf synchrones better-sqlite3 umgestellt.
===================================================================== */
import { Router } from "express";
import { db } from "../db.js";
export const bestsellerRouter = Router();
bestsellerRouter.get("/api/bestseller", (req, res) => {
try {
const results = db
.prepare(
`SELECT oi.produkt_slug AS slug, SUM(oi.menge) AS verkauft
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE o.status NOT IN ('storniert', 'zahlungOffen') AND oi.gratis = 0
GROUP BY oi.produkt_slug
ORDER BY verkauft DESC, slug ASC
LIMIT 6`
)
.all();
return res.json({ ok: true, slugs: results.map((r) => r.slug) });
} catch {
return res.json({ ok: true, slugs: [] });
}
});
+31
View File
@@ -0,0 +1,31 @@
/* =====================================================================
routes/orders.js — POST /api/orders, legt eine Überweisungs-Bestellung (Vorkasse) an.
1:1 portiert aus functions/api/orders.js. Lehnt zahlungsart:"paypal" bewusst ab (Betrugsschutz
— PayPal-Bestellungen entstehen NUR über routes/paypal.js, nach echter Zahlungsprüfung).
===================================================================== */
import { Router } from "express";
import { erstelleBestellung } from "../lib/bestellung-erstellen.js";
export const ordersRouter = Router();
ordersRouter.post("/api/orders", async (req, res) => {
const body = req.body || {};
const zahlungsart = String(body.zahlungsart || "");
if (zahlungsart === "paypal") {
return res.status(400).json({
ok: false,
error: "PayPal-Bestellungen laufen über den PayPal-Button im Checkout, nicht über diese Route.",
});
}
if (zahlungsart !== "ueberweisung") {
return res.status(400).json({ ok: false, error: "Ungültige oder nicht unterstützte Zahlungsart." });
}
const ergebnis = await erstelleBestellung(body, { status: "zahlungOffen" });
if (!ergebnis.ok) {
return res.status(ergebnis.httpStatus || 500).json({ ok: false, error: ergebnis.error });
}
return res.status(201).json({ ok: true, id: ergebnis.id, bestellnummer: ergebnis.bestellnummer });
});
+75
View File
@@ -0,0 +1,75 @@
/* =====================================================================
routes/paypal.js — POST /api/paypal/create-order + /api/paypal/capture-order. 1:1 portiert
aus functions/api/paypal/create-order.js + capture-order.js. Der entscheidende Sicherheits-
Schritt bleibt unverändert: die Bestellung wird erst NACH server-seitig bei PayPal bestätigter
Zahlung angelegt, mit Betragsabgleich (Toleranz 0,02 €).
===================================================================== */
import { Router } from "express";
import { paypalKonfiguriert, erstellePaypalBestellung, erfassePaypalZahlung } from "../lib/paypal.js";
import { erstelleBestellung } from "../lib/bestellung-erstellen.js";
export const paypalRouter = Router();
const BETRAG_TOLERANZ_EURO = 0.02;
paypalRouter.post("/api/paypal/create-order", async (req, res) => {
if (!paypalKonfiguriert()) {
return res.status(503).json({
ok: false,
error: "PayPal ist auf dieser Seite noch nicht eingerichtet (fehlende Zugangsdaten). Bitte eine andere Zahlungsart wählen.",
});
}
const body = req.body || {};
const summe = Number(body.summe);
if (!Number.isFinite(summe) || summe <= 0) {
return res.status(400).json({ ok: false, error: "Ungültiger Betrag." });
}
try {
const paypalOrderId = await erstellePaypalBestellung(summe);
return res.json({ ok: true, id: paypalOrderId });
} catch (err) {
return res.status(502).json({ ok: false, error: err instanceof Error ? err.message : "PayPal-Bestellung fehlgeschlagen." });
}
});
paypalRouter.post("/api/paypal/capture-order", async (req, res) => {
if (!paypalKonfiguriert()) {
return res.status(503).json({ ok: false, error: "PayPal ist auf dieser Seite noch nicht eingerichtet." });
}
const body = req.body || {};
const paypalOrderId = typeof body.paypalOrderId === "string" ? body.paypalOrderId.trim() : "";
if (!paypalOrderId) {
return res.status(400).json({ ok: false, error: "Fehlende PayPal-Bestell-ID." });
}
const summe = Number(body.summe);
if (!Number.isFinite(summe) || summe <= 0) {
return res.status(400).json({ ok: false, error: "Ungültige Bestellsumme." });
}
const zahlung = await erfassePaypalZahlung(paypalOrderId);
if (!zahlung.ok) {
return res.status(402).json({ ok: false, error: `Zahlung konnte nicht bestätigt werden: ${zahlung.error}` });
}
if (Math.abs(zahlung.betragEuro - summe) > BETRAG_TOLERANZ_EURO) {
return res.status(402).json({
ok: false,
error: `Der bei PayPal gezahlte Betrag (${zahlung.betragEuro.toFixed(2)} €) stimmt nicht mit der Bestellsumme (${summe.toFixed(2)} €) überein.`,
});
}
const ergebnis = await erstelleBestellung({ ...body, zahlungsart: "paypal" }, { status: "bezahlt", paypalOrderId });
if (!ergebnis.ok) {
return res.status(500).json({
ok: false,
error: `Deine Zahlung wurde erfolgreich bei PayPal abgebucht (Beleg-ID: ${zahlung.captureId}), aber die Bestellung konnte nicht gespeichert werden. Bitte kontaktiere uns mit dieser Beleg-ID, damit wir das von Hand nachtragen — bitte NICHT erneut bezahlen.`,
});
}
return res.status(201).json({ ok: true, id: ergebnis.id, bestellnummer: ergebnis.bestellnummer });
});
+95
View File
@@ -0,0 +1,95 @@
/* =====================================================================
routes/verwaltung.js — GET /api/verwaltung/orders (Liste) + PATCH /api/verwaltung/orders/:id
(Status/Tracking ändern). 1:1 portiert aus functions/api/verwaltung/orders.js +
functions/api/verwaltung/orders/[id].js. Prüft zusätzlich zur gate-Middleware (siehe
middleware/gate.js) hier nochmal eigenständig beide Schranken — Verteidigung in der Tiefe.
===================================================================== */
import { Router } from "express";
import { getGateRole, unauthorizedJson } from "../lib/auth.js";
import { hatGueltigeVerwaltungSitzung } from "../lib/verwaltung-auth.js";
import { db } from "../db.js";
export const verwaltungRouter = Router();
const ERLAUBTE_STATUS = ["zahlungOffen", "bezahlt", "bearbeitung", "versandVorbereitet", "versendet", "abgeschlossen", "storniert"];
async function pruefeSchranken(req, res) {
const role = await getGateRole(req);
if (!role) {
unauthorizedJson(res);
return false;
}
if (!(await hatGueltigeVerwaltungSitzung(req))) {
res.status(401).json({ ok: false, error: "Zusätzlicher Verwaltungs-Code erforderlich." });
return false;
}
return true;
}
verwaltungRouter.get("/api/verwaltung/orders", async (req, res) => {
if (!(await pruefeSchranken(req, res))) return;
try {
const orders = db.prepare(`SELECT * FROM orders ORDER BY created_at DESC`).all();
if (orders.length === 0) return res.json({ ok: true, orders: [] });
const ids = orders.map((o) => o.id);
const placeholders = ids.map(() => "?").join(",");
const items = db.prepare(`SELECT * FROM order_items WHERE order_id IN (${placeholders}) ORDER BY id ASC`).all(...ids);
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 res.json({ ok: true, orders: result });
} catch (err) {
return res.status(500).json({ ok: false, error: "Bestellungen konnten nicht geladen werden." });
}
});
verwaltungRouter.patch("/api/verwaltung/orders/:id", async (req, res) => {
if (!(await pruefeSchranken(req, res))) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ ok: false, error: "Ungültige Bestell-ID." });
}
const body = req.body || {};
const updates = [];
const values = [];
if (body.status !== undefined) {
if (!ERLAUBTE_STATUS.includes(body.status)) {
return res.status(400).json({ 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 res.status(400).json({ ok: false, error: "Nichts zu ändern übergeben." });
}
updates.push("updated_at = ?");
values.push(new Date().toISOString());
values.push(id);
try {
const result = db.prepare(`UPDATE orders SET ${updates.join(", ")} WHERE id = ?`).run(...values);
if (result.changes === 0) {
return res.status(404).json({ ok: false, error: "Bestellung nicht gefunden." });
}
return res.json({ ok: true });
} catch (err) {
return res.status(500).json({ ok: false, error: "Bestellung konnte nicht aktualisiert werden." });
}
});
@@ -0,0 +1,46 @@
{
"bilder": [
"/img/products/600.png"
],
"name": {
"de": "Glasperlenarmbänder - \"Klein/3er-Set\" Gr. 16",
"en": "Glass bead bracelets Small/set of 3 Size 16",
"ch": "Glasperlenarmbänder - \"Klein/3er-Set\" Gr. 16",
"fr": "Bracelets en perles de verre - « Petit / lot de 3 » taille 16"
},
"beschreibung": {
"de": "Handgefertigte Glasperlenarmbänder liebevolle Anfertigung\n\nDieses handgefertigten Armbänder wurden mit dekorativen Glasperlen in sorgfältiger Handarbeit hergestellt. Die verwendeten Glasperlen stammen von internationalen Lieferanten und überzeugen durch ihre schöne Optik sowie ihre vielseitigen Farb- und Designmöglichkeiten.\n\nJedes Armband ist ein individuelles Schmuckstück und eignet sich hervorragend als modisches Accessoire für den Alltag, besondere Anlässe oder als Geschenk für Familie, Freunde und Schmuckliebhaber.",
"en": "Handcrafted glass bead bracelets lovingly made\n\nThese handcrafted bracelets have been carefully made by hand using decorative glass beads. The glass beads used are sourced from international suppliers and impress with their beautiful appearance and wide range of colour and design options.\n\nEach bracelet is a unique piece of jewellery and makes an excellent fashion accessory for everyday wear, special occasions or as a gift for family, friends and jewellery lovers.",
"ch": "Handgfertigti Glasperlenarmbänder liebevolli Aafertigung\n\nDas handgfertigte Armbänder sind mit dekorative Glasperle i sorgfältiger Handarbet härgstellt worde. Die verwändete Glasperle stamme vo internationale Lieferante und überzüüged dur ihri schöni Optik und ihri vielsiitige Farb- und Designmöglichkeite.\n\nJedes Armband isch es individuells Schmuckstück und eignet sich hervorragend als modischs Accessoire für de Alltag, bsunders Aläss oder als Gschänk für Familie, Fründe und Schmuckliebhaber.",
"fr": "Bracelets en perles de verre faits main une fabrication soignée\n\nCes bracelets faits main ont été réalisés avec soin à la main à partir de perles de verre décoratives. Les perles de verre utilisées proviennent de fournisseurs internationaux et séduisent par leur bel aspect ainsi que par la diversité des couleurs et des motifs disponibles.\n\nChaque bracelet est une pièce unique et constitue un accessoire de mode idéal pour le quotidien, les occasions spéciales ou comme cadeau pour la famille, les amis et les amateurs de bijoux."
},
"bestand": 1,
"kategorie": "modeschmuck-accessoires",
"badges": [
"handgemacht",
"neu"
],
"unterkategorie": "glasperlenarmbaender",
"charakter": "",
"preis": 3.5,
"preisAlt": null,
"versand": {
"de": 1.8,
"at": 2.8,
"ch": 3.4,
"lu": 2.8
},
"artikelnummer": "GPA-0600",
"material": "Glasperlen; elastisches Gummiband",
"groesse": "ca. 16 // 4mm Perlen // 16g",
"farben": [
"rosa",
"pink",
"hellblau",
"schwarz"
],
"lieferumfang": "",
"pflegehinweise": "",
"lieferzeit": "27 Werktage",
"slug": "glasperlenarmband-klein-set"
}
@@ -1,12 +1,12 @@
{
"bilder": [
"/img/products/500.png"
"/img/products/501.png"
],
"name": {
"de": "Glasperlenarmband - \"Lila/Accessoires\" Gr. 17",
"en": "Glass bead bracelet Purple/Accessories Size 17",
"ch": "Glasperlenarmband - \"Lila/Accessoires\" Gr. 17",
"fr": "Bracelet en perles de verre - « Violet/Accessoires » taille 17"
"de": "Glasperlenarmband - \"Rosa/Accessoires\" Gr. 17",
"en": "Glass bead bracelet Pink/Accessories Size 17",
"ch": "Glasperlenarmband - \"Rosa/Accessoires\" Gr. 17",
"fr": "Bracelet en perles de verre - « Rose/Accessoires » taille 17"
},
"beschreibung": {
"de": "Handgefertigtes Glasperlenarmband liebevolle Anfertigung\n\nDieses handgefertigte Armband wurde mit dekorativen Glasperlen in sorgfältiger Handarbeit hergestellt. Die verwendeten Glasperlen stammen von internationalen Lieferanten und überzeugen durch ihre schöne Optik sowie ihre vielseitigen Farb- und Designmöglichkeiten.\n\nJedes Armband ist ein individuelles Schmuckstück und eignet sich hervorragend als modisches Accessoire für den Alltag, besondere Anlässe oder als Geschenk für Familie, Freunde und Schmuckliebhaber.",
@@ -30,16 +30,15 @@
"ch": 3.4,
"lu": 2.8
},
"artikelnummer": "GPA-0500",
"artikelnummer": "GPA-0501",
"material": "Glasperlen; schwarzfarbene Accessoires; elastisches Gummiband",
"groesse": "ca. 17 // 10mm Perlen // 27g",
"groesse": "ca. 17 // 10mm Perlen // 28g",
"farben": [
"lila",
"creme",
"rosa",
"schwarz"
],
"lieferumfang": "",
"pflegehinweise": "",
"lieferzeit": "27 Werktage",
"slug": "glasperlenarmband-lila-accessoires"
"slug": "glasperlenarmband-rosa-accessoires"
}
@@ -32,7 +32,7 @@
"lu": 2.8
},
"artikelnummer": "GPA-0402",
"material": "Glasperlen ; silberfarbener Charm mit Stein; elastisches Gummiband",
"material": "Glasperlen ; silberfarbener Charm mit Stein; silberfarbene Accessoires; elastisches Gummiband",
"groesse": "ca. 19 // 10mm Perlen // 29g",
"farben": [
"lila",
@@ -32,7 +32,7 @@
"lu": 2.8
},
"artikelnummer": "GPA-0301",
"material": "Glasperlen ; silberfarbener Charm mit Stein; elastisches Gummiband",
"material": "Glasperlen ; silberfarbener Charm mit Stein; silberfarbene Accessoires; elastisches Gummiband",
"groesse": "ca. 16 // 10mm Perlen // 28g",
"farben": [
"lila",
@@ -0,0 +1,47 @@
{
"bilder": [
"/img/products/700.png",
"/img/products/700_2.png",
"/img/products/700_3.png"
],
"name": {
"de": "Schlüsselanhänger - \"Glas/Note/Gold\"",
"en": "Key ring Glass/Note/Gold",
"ch": "Schlüsselanhänger - \"Glas/Note/Gold\"",
"fr": "Porte-clés - « Verre/Note/Or »"
},
"beschreibung": {
"de": "Handgefertigter Schlüsselanhänger Individuelles Unikat\n\nDieser handgefertigte Schlüsselanhänger wurde mit viel Sorgfalt und Liebe zum Detail hergestellt. Er eignet sich ideal zur Kennzeichnung von Schlüsseln, als dekorativer Anhänger für Taschen, Rucksäcke oder als kleines Geschenk für Familie, Freunde und Kollegen.\n\nDurch seine individuelle Gestaltung ist jeder Schlüsselanhänger ein besonderer Blickfang und verleiht Alltagsgegenständen eine persönliche Note.",
"en": "Handgefertigter Schlüsselanhänger Individuelles Unikat\n\nDieser handgefertigte Schlüsselanhänger wurde mit viel Sorgfalt und Liebe zum Detail hergestellt. Er eignet sich ideal zur Kennzeichnung von Schlüsseln, als dekorativer Anhänger für Taschen, Rucksäcke oder als kleines Geschenk für Familie, Freunde und Kollegen.\n\nThanks to its unique design, every key ring is a real eye-catcher and adds a personal touch to everyday items.",
"ch": "Handgfertigte Schlüsselahänger Individuells Unikat\n\nDä handgfertigti Schlüsselahänger isch mit viel Sorgfalt und Liebi zum Detail hergstellt worde. Er eignet sich ideal zur Kennzeichnig vo Schlüssle, als dekorative Aahänger für Täsche, Rucksäck oder als chliises Gschänk für Familie, Fründe und Kollege.\n\nDur sini individuelli Gstaltig isch jede Schlüsselahänger en bsundere Blickfang und verleiht Alltagsgegeständ e persönlichi Note.",
"fr": "Porte-clés fait main Pièce unique et personnalisée\n\nCe porte-clés fait main a été réalisé avec beaucoup de soin et d'attention aux détails. Il est idéal pour identifier vos clés, comme accessoire décoratif pour vos sacs ou sacs à dos, ou encore comme petit cadeau pour votre famille, vos amis et vos collègues.\n\nGrâce à son design personnalisé, chaque porte-clés attire particulièrement l'œil et apporte une touche personnelle aux objets du quotidien."
},
"bestand": 1,
"kategorie": "modeschmuck-accessoires",
"badges": [
"handgemacht",
"neu"
],
"unterkategorie": "schluesselanhaenger",
"charakter": "",
"preis": 4.5,
"preisAlt": null,
"versand": {
"de": 2.7,
"at": 2.8,
"ch": 3.4,
"lu": 2.8
},
"artikelnummer": "GPA-0700",
"material": "Glas; goldfarbene Kunststoffdeko; gold-/weißfarbener Charm; stabiler Schlüsselring",
"groesse": "ca. 10cm // 17g",
"farben": [
"gold",
"weiß",
"silber"
],
"lieferumfang": "",
"pflegehinweise": "",
"lieferzeit": "27 Werktage",
"slug": "schluesselanhaenger-glas-note-gold"
}
+3
View File
@@ -22,7 +22,10 @@ const items = saleProducts();
<section class="section-tight">
<div class="container">
{items.length > 0 ? (
<>
<p class="small result-count-static">{t.shop.resultCount(items.length)}</p>
<div class="grid grid-3">{items.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</>
) : (
<p class="small">{t.sale.empty}</p>
)}
+3
View File
@@ -47,7 +47,10 @@ const items = productsByCategory(category.slug);
</>
)}
{items.length > 0 ? (
<>
<p class="small result-count-static">{t.shop.resultCount(items.length)}</p>
<div class="grid grid-3">{items.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</>
) : (
<p class="small">{t.shop.categoryEmpty}</p>
)}
@@ -44,8 +44,14 @@ const hatStoryBoxen = characterEntriesFuerStory.length > 0;
const hatProduktKarten = characterEntriesMitProdukt.length > 0;
const hatWeitere = weitere.length > 0;
const komplettLeer = !hatProduktKarten && !hatWeitere;
// Gesamtzahl ECHTER Produkte auf dieser Seite (Charakter-Karten + "weitere Modelle" zusammen,
// OHNE die reinen Platzhalterkarten) — auf ausdrücklichen Wunsch (04.08.2026): auf wirklich jeder
// Seite mit einer Produktliste soll man auf einen Blick sehen, wie viele Artikel dort stehen.
const gesamtProdukte = characterEntriesMitProdukt.length + weitere.length;
const platzhalterInStorySektion = hatStoryBoxen && komplettLeer;
const eigeneLeerSektion = !hatStoryBoxen && komplettLeer;
const zeigeAnzahlInStorySektion = hatStoryBoxen;
const zeigeAnzahlInWeitereSektion = !hatStoryBoxen && hatWeitere;
---
<Layout title={`${sub.name[lang]} ${category.name[lang]}`} description={`${sub.name[lang]} bi Van's DIY & Bastelbedarf.`} lang={lang} path={`/shop/${category.slug}/${sub.slug}/`}>
<div class="container">
@@ -63,6 +69,7 @@ const eigeneLeerSektion = !hatStoryBoxen && komplettLeer;
{hatStoryBoxen && (
<section class="section-tight">
<div class="container">
{gesamtProdukte > 0 && zeigeAnzahlInStorySektion && <p class="small result-count-static">{t.shop.resultCount(gesamtProdukte)}</p>}
<div class="character-stories">
{characterEntriesFuerStory.map(({ char }) => (
<div class="card character-story">
@@ -121,6 +128,7 @@ const eigeneLeerSektion = !hatStoryBoxen && komplettLeer;
{hatWeitere && (
<section class="section-tight">
<div class="container">
{gesamtProdukte > 0 && zeigeAnzahlInWeitereSektion && <p class="small result-count-static">{t.shop.resultCount(gesamtProdukte)}</p>}
{hatProduktKarten && <h2 class="text-center small more-variants-label">{t.shop.moreVariants}</h2>}
<div class="grid grid-3">{weitere.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</div>
+3
View File
@@ -22,7 +22,10 @@ const items = saleProducts();
<section class="section-tight">
<div class="container">
{items.length > 0 ? (
<>
<p class="small result-count-static">{t.shop.resultCount(items.length)}</p>
<div class="grid grid-3">{items.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</>
) : (
<p class="small">{t.sale.empty}</p>
)}
+3
View File
@@ -47,7 +47,10 @@ const items = productsByCategory(category.slug);
</>
)}
{items.length > 0 ? (
<>
<p class="small result-count-static">{t.shop.resultCount(items.length)}</p>
<div class="grid grid-3">{items.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</>
) : (
<p class="small">{t.shop.categoryEmpty}</p>
)}
@@ -44,8 +44,14 @@ const hatStoryBoxen = characterEntriesFuerStory.length > 0;
const hatProduktKarten = characterEntriesMitProdukt.length > 0;
const hatWeitere = weitere.length > 0;
const komplettLeer = !hatProduktKarten && !hatWeitere;
// Gesamtzahl ECHTER Produkte auf dieser Seite (Charakter-Karten + "weitere Modelle" zusammen,
// OHNE die reinen Platzhalterkarten) — auf ausdrücklichen Wunsch (04.08.2026): auf wirklich jeder
// Seite mit einer Produktliste soll man auf einen Blick sehen, wie viele Artikel dort stehen.
const gesamtProdukte = characterEntriesMitProdukt.length + weitere.length;
const platzhalterInStorySektion = hatStoryBoxen && komplettLeer;
const eigeneLeerSektion = !hatStoryBoxen && komplettLeer;
const zeigeAnzahlInStorySektion = hatStoryBoxen;
const zeigeAnzahlInWeitereSektion = !hatStoryBoxen && hatWeitere;
---
<Layout title={`${sub.name[lang]} ${category.name[lang]}`} description={`${sub.name[lang]} at Van's DIY & Bastelbedarf.`} lang={lang} path={`/shop/${category.slug}/${sub.slug}/`}>
<div class="container">
@@ -63,6 +69,7 @@ const eigeneLeerSektion = !hatStoryBoxen && komplettLeer;
{hatStoryBoxen && (
<section class="section-tight">
<div class="container">
{gesamtProdukte > 0 && zeigeAnzahlInStorySektion && <p class="small result-count-static">{t.shop.resultCount(gesamtProdukte)}</p>}
<div class="character-stories">
{characterEntriesFuerStory.map(({ char }) => (
<div class="card character-story">
@@ -121,6 +128,7 @@ const eigeneLeerSektion = !hatStoryBoxen && komplettLeer;
{hatWeitere && (
<section class="section-tight">
<div class="container">
{gesamtProdukte > 0 && zeigeAnzahlInWeitereSektion && <p class="small result-count-static">{t.shop.resultCount(gesamtProdukte)}</p>}
{hatProduktKarten && <h2 class="text-center small more-variants-label">{t.shop.moreVariants}</h2>}
<div class="grid grid-3">{weitere.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</div>
+3
View File
@@ -22,7 +22,10 @@ const items = saleProducts();
<section class="section-tight">
<div class="container">
{items.length > 0 ? (
<>
<p class="small result-count-static">{t.shop.resultCount(items.length)}</p>
<div class="grid grid-3">{items.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</>
) : (
<p class="small">{t.sale.empty}</p>
)}
+3
View File
@@ -47,7 +47,10 @@ const items = productsByCategory(category.slug);
</>
)}
{items.length > 0 ? (
<>
<p class="small result-count-static">{t.shop.resultCount(items.length)}</p>
<div class="grid grid-3">{items.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</>
) : (
<p class="small">{t.shop.categoryEmpty}</p>
)}
@@ -44,8 +44,14 @@ const hatStoryBoxen = characterEntriesFuerStory.length > 0;
const hatProduktKarten = characterEntriesMitProdukt.length > 0;
const hatWeitere = weitere.length > 0;
const komplettLeer = !hatProduktKarten && !hatWeitere;
// Gesamtzahl ECHTER Produkte auf dieser Seite (Charakter-Karten + "weitere Modelle" zusammen,
// OHNE die reinen Platzhalterkarten) — auf ausdrücklichen Wunsch (04.08.2026): auf wirklich jeder
// Seite mit einer Produktliste soll man auf einen Blick sehen, wie viele Artikel dort stehen.
const gesamtProdukte = characterEntriesMitProdukt.length + weitere.length;
const platzhalterInStorySektion = hatStoryBoxen && komplettLeer;
const eigeneLeerSektion = !hatStoryBoxen && komplettLeer;
const zeigeAnzahlInStorySektion = hatStoryBoxen;
const zeigeAnzahlInWeitereSektion = !hatStoryBoxen && hatWeitere;
---
<Layout title={`${sub.name[lang]} ${category.name[lang]}`} description={`${sub.name[lang]} chez Van's DIY & Bastelbedarf.`} lang={lang} path={`/shop/${category.slug}/${sub.slug}/`}>
<div class="container">
@@ -63,6 +69,7 @@ const eigeneLeerSektion = !hatStoryBoxen && komplettLeer;
{hatStoryBoxen && (
<section class="section-tight">
<div class="container">
{gesamtProdukte > 0 && zeigeAnzahlInStorySektion && <p class="small result-count-static">{t.shop.resultCount(gesamtProdukte)}</p>}
<div class="character-stories">
{characterEntriesFuerStory.map(({ char }) => (
<div class="card character-story">
@@ -121,6 +128,7 @@ const eigeneLeerSektion = !hatStoryBoxen && komplettLeer;
{hatWeitere && (
<section class="section-tight">
<div class="container">
{gesamtProdukte > 0 && zeigeAnzahlInWeitereSektion && <p class="small result-count-static">{t.shop.resultCount(gesamtProdukte)}</p>}
{hatProduktKarten && <h2 class="text-center small more-variants-label">{t.shop.moreVariants}</h2>}
<div class="grid grid-3">{weitere.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</div>
+3
View File
@@ -22,7 +22,10 @@ const items = saleProducts();
<section class="section-tight">
<div class="container">
{items.length > 0 ? (
<>
<p class="small result-count-static">{t.shop.resultCount(items.length)}</p>
<div class="grid grid-3">{items.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</>
) : (
<p class="small">{t.sale.empty}</p>
)}
+3
View File
@@ -47,7 +47,10 @@ const items = productsByCategory(category.slug);
</>
)}
{items.length > 0 ? (
<>
<p class="small result-count-static">{t.shop.resultCount(items.length)}</p>
<div class="grid grid-3">{items.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</>
) : (
<p class="small">{t.shop.categoryEmpty}</p>
)}
@@ -53,12 +53,25 @@ const hatWeitere = weitere.length > 0;
// eingetragen hat: statt einer nackten Text-Zeile zeigen wir schon die spätere Karten-Optik,
// nur eben mit Platzhaltern. Sobald echte Produkte dazukommen, verschwinden diese automatisch.
const komplettLeer = !hatProduktKarten && !hatWeitere;
// Gesamtzahl ECHTER Produkte auf dieser Seite (Charakter-Karten + "weitere Modelle" zusammen,
// OHNE die reinen Platzhalterkarten) — auf ausdrücklichen Wunsch (04.08.2026): auf wirklich jeder
// Seite mit einer Produktliste soll man auf einen Blick sehen, wie viele Artikel dort stehen.
const gesamtProdukte = characterEntriesMitProdukt.length + weitere.length;
// Die Platzhalterkarten müssen direkt in derselben Sektion wie die Story-Boxen stehen (nicht in
// einer eigenen, zweiten <section>), sonst addieren sich zwei section-Innenabstände zu einem viel
// zu großen Zwischenraum. Nur wenn es gar keine Story-Boxen gibt (z.B. Unterkategorien ohne
// Charaktere), bekommen die Platzhalter ihre eigene Sektion wie bisher.
const platzhalterInStorySektion = hatStoryBoxen && komplettLeer;
const eigeneLeerSektion = !hatStoryBoxen && komplettLeer;
// Nutzer-Wunsch 04.08.2026 (2. Runde): "die Produktanzahl soll über dem 1. Produkt sein und
// nicht oben genau unter dem großen Bild — überall." Vorher stand die Zahl direkt unter dem
// Banner, in einem eigenen Container, unabhängig davon, wo die erste echte Produktkarte
// überhaupt auftaucht (bei Unterkategorien mit Charakter-Story-Boxen lag zwischen Zahl und
// erster Karte z.B. noch die ganze Vorstellungs-Box). Jetzt steht die Zahl direkt über der
// ERSTEN Sektion, die wirklich Produktkarten zeigt — genau wie es shop/[kategorie].astro und
// sale/index.astro schon immer gemacht haben (dort war es nie falsch platziert).
const zeigeAnzahlInStorySektion = hatStoryBoxen;
const zeigeAnzahlInWeitereSektion = !hatStoryBoxen && hatWeitere;
---
<Layout title={`${sub.name[lang]} ${category.name[lang]}`} description={`${sub.name[lang]} bei Van's DIY & Bastelbedarf.`} lang={lang} path={`/shop/${category.slug}/${sub.slug}/`}>
<div class="container">
@@ -76,6 +89,7 @@ const eigeneLeerSektion = !hatStoryBoxen && komplettLeer;
{hatStoryBoxen && (
<section class="section-tight">
<div class="container">
{gesamtProdukte > 0 && zeigeAnzahlInStorySektion && <p class="small result-count-static">{t.shop.resultCount(gesamtProdukte)}</p>}
{/* Die persönlichen Charaktergeschichten stehen als eigene, volle Breite Boxen ÜBER der
Kartenreihe — getrennt von den "Shop"-Karten darunter, die nur Foto/Preis/Infos zeigen.
Der Haupt-Charakter der Familie bekommt immer eine Box, weitere Charaktere nur mit
@@ -142,6 +156,7 @@ const eigeneLeerSektion = !hatStoryBoxen && komplettLeer;
{hatWeitere && (
<section class="section-tight">
<div class="container">
{gesamtProdukte > 0 && zeigeAnzahlInWeitereSektion && <p class="small result-count-static">{t.shop.resultCount(gesamtProdukte)}</p>}
{hatProduktKarten && <h2 class="text-center small more-variants-label">{t.shop.moreVariants}</h2>}
<div class="grid grid-3">{weitere.map((p) => <ProductCard product={p} lang={lang} />)}</div>
</div>
+6
View File
@@ -105,6 +105,12 @@ img { max-width: 100%; display: block; }
.text-center { text-align: center; }
.small { font-size: 0.85rem; color: var(--c-text-muted); }
/* Statische Artikel-Anzahl (Kategorie-/Unterkategorie-/Sale-Seiten, siehe die jeweiligen
src/pages/.../index.astro) — server-seitig fest berechnet, anders als das gleich aussehende
#result-count im Shop-Filter, das sich per JS live an die Filter anpasst. Auf ausdrücklichen
Wunsch (04.08.2026): auf wirklich jeder Seite mit einer Produktliste soll man auf einen Blick
sehen, wie viele Artikel dort stehen, nicht nur im Haupt-Shop. */
.result-count-static { margin: 0.9rem 0 1rem; }
/* Buttons */
.btn {