Gutscheine "nur einmal pro Person" + Bestell-E-Mail-Benachrichtigung
1) Gutscheincodes können jetzt im Adminbereich auf "nur einmal pro Person einlösbar" gestellt werden. Wird serverseitig durchgesetzt (neue Spalte orders.gutschein_code + Prüfung in functions/api/orders.js gegen die echte Bestelldatenbank, case-insensitiv nach E-Mail-Adresse) — kann also NICHT durch Löschen von localStorage/einen anderen Browser umgangen werden, anders als die bisherige rein clientseitige Rabattlogik. Stornierte Bestellungen zählen nicht als "schon benutzt". Ausführlich getestet: echter Browser-Checkout mit Einmal-Code funktioniert beim ersten Versuch, wird beim zweiten Versuch (gleiche E-Mail) korrekt mit klarer Fehlermeldung abgelehnt, mit anderer E-Mail weiterhin nutzbar. 2) Bestell-E-Mail-Benachrichtigung an [email protected]: recherchiert und vorbereitet (existierte vorher nirgends im Code). Läuft über Resend (kostenlos bis 3.000 E-Mails/Monat), im Hintergrund per context.waitUntil() nach jeder neuen Bestellung — verzögert nie die Antwort an die Kundschaft und kann eine Bestellung niemals zum Scheitern bringen, selbst wenn der Versand fehlschlägt (mit ungültigem Testschlüssel verifiziert: Bestellung bleibt trotzdem erfolgreich gespeichert). Wird automatisch aktiv, sobald ein RESEND_API_KEY als Secret hinterlegt wird. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0c1c8f075f
commit
af729ba571
@@ -0,0 +1,5 @@
|
||||
-- Nachträgliche Erweiterung für "Gutschein nur einmal pro Person" (siehe functions/api/orders.js).
|
||||
-- Einmalig auf die schon bestehende Datenbank anzuwenden (lokal UND remote) — für neue
|
||||
-- Datenbanken ist die Spalte schon direkt in d1/schema.sql enthalten.
|
||||
ALTER TABLE orders ADD COLUMN gutschein_code TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_gutschein_email ON orders(gutschein_code, kunde_email);
|
||||
@@ -27,6 +27,11 @@ CREATE TABLE IF NOT EXISTS orders (
|
||||
ort TEXT,
|
||||
zahlungsart TEXT,
|
||||
tracking TEXT,
|
||||
-- Eingelöster Gutscheincode (normalisiert, Großbuchstaben) — NULL, wenn keiner verwendet wurde.
|
||||
-- Wird gebraucht, um "nur einmal pro Person"-Gutscheine serverseitig durchzusetzen (siehe
|
||||
-- functions/api/orders.js): vor dem Anlegen einer neuen Bestellung wird geprüft, ob dieselbe
|
||||
-- E-Mail-Adresse denselben Code schon einmal (in einer nicht-stornierten Bestellung) benutzt hat.
|
||||
gutschein_code TEXT,
|
||||
zwischensumme REAL NOT NULL DEFAULT 0,
|
||||
versandkosten REAL NOT NULL DEFAULT 0,
|
||||
rabatt_gesamt REAL NOT NULL DEFAULT 0,
|
||||
@@ -50,3 +55,4 @@ 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);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_gutschein_email ON orders(gutschein_code, kunde_email);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/* =====================================================================
|
||||
functions/_shared/email.js — E-Mail-Versand über Resend (kostenlos bis 3.000 E-Mails/Monat,
|
||||
100/Tag — für einen kleinen Handmade-Shop mehr als ausreichend, siehe Recherche im Vault-
|
||||
Tagesprotokoll). Bewusst NICHT über Cloudflare Email Routing/Workers Send-Email-Binding
|
||||
gelöst, weil das einen bereits verifizierten, eigenen Domain-Versand voraussetzt — die Seite
|
||||
läuft aktuell noch auf *.pages.dev ohne eigene Domain. Resend funktioniert dagegen sofort mit
|
||||
der kostenlosen "[email protected]"-Absenderadresse, ganz ohne eigene Domain-Verifizierung.
|
||||
|
||||
WICHTIG: E-Mail-Versand darf NIE eine Bestellung zum Scheitern bringen — die Bestellung ist
|
||||
in der Datenbank (D1) längst sicher gespeichert, bevor diese Funktion überhaupt aufgerufen
|
||||
wird. Deshalb wirft sendResendEmail() nie einen Fehler nach außen, sondern gibt nur
|
||||
{ ok: true/false, error? } zurück — der Aufrufer entscheidet selbst, ob/wie er einen
|
||||
Fehlschlag protokolliert (siehe functions/api/orders.js). ===================================================================== */
|
||||
|
||||
const RESEND_API_URL = "https://api.resend.com/emails";
|
||||
const STANDARD_ABSENDER = "Van's DIY & Bastelbedarf <[email protected]>";
|
||||
|
||||
/**
|
||||
* Verschickt eine E-Mail über die Resend-API. Gibt IMMER ein Ergebnisobjekt zurück (nie eine
|
||||
* geworfene Exception), damit der Aufrufer den Versand einfach "fire and forget" behandeln kann.
|
||||
* `env.RESEND_API_KEY` fehlt einfach so lange, bis VanVan/qciga einen kostenlosen Resend-Account
|
||||
* eingerichtet haben — bis dahin liefert diese Funktion sauber `{ ok: false, error: "..." }`
|
||||
* zurück, ohne irgendetwas kaputtzumachen.
|
||||
*/
|
||||
export async function sendeEmail(env, { to, subject, html, text, from }) {
|
||||
if (!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 ${env.RESEND_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: from || 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) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Escaped Text sicher fürs Einbetten in eine HTML-E-Mail (verhindert kaputtes HTML bei z.B.
|
||||
* Sonderzeichen im Kundennamen). */
|
||||
export function escapeHtmlFuerEmail(str) {
|
||||
return String(str ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
+84
-3
@@ -12,8 +12,50 @@
|
||||
gilt sofort als "bezahlt" (siehe scripts/account.ts, derselbe Grundsatz). ===================================================================== */
|
||||
|
||||
import { json } from "../_shared/http.js";
|
||||
import { sendeEmail, escapeHtmlFuerEmail } from "../_shared/email.js";
|
||||
// Gleiche Gutschein-Daten wie im Frontend (src/data/gutscheine.ts) — hier server-seitig erneut
|
||||
// eingebunden, um das "nur einmal pro Person"-Flag unabhängig vom Browser durchzusetzen (siehe
|
||||
// unten). Bewusst derselbe JSON-Rohdaten-Import, keine Code-Duplizierung der Inhalte.
|
||||
// @ts-ignore
|
||||
import gutscheineRaw from "../../src/content/gutscheine.json";
|
||||
|
||||
const ERLAUBTE_LAENDER = ["de", "at", "ch", "lu"];
|
||||
const gutscheine = (gutscheineRaw && gutscheineRaw.codes) || [];
|
||||
// VanVans Wunsch-Adresse für Bestellbenachrichtigungen (siehe Anfrage vom 03.08.2026). Fest
|
||||
// hinterlegt statt als Secret, da es keine geheime Information ist (nur ein Empfänger).
|
||||
const BENACHRICHTIGUNGS_EMAIL = "[email protected]";
|
||||
|
||||
const LAND_NAME = { de: "Deutschland", at: "Österreich", ch: "Schweiz", lu: "Luxemburg" };
|
||||
|
||||
function formatPreis(n) {
|
||||
return `${Number(n).toFixed(2).replace(".", ",")} €`;
|
||||
}
|
||||
|
||||
/** Baut die interne "neue Bestellung eingegangen"-E-Mail an VanVan/qciga — bewusst schlicht
|
||||
* gehalten (kein Marketing-Layout nötig, nur die wichtigsten Infos auf einen Blick + Link zur
|
||||
* Verwaltung). */
|
||||
function bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe }) {
|
||||
const artikelZeilen = artikel
|
||||
.map((a) => `<li>${a.menge}× ${escapeHtmlFuerEmail(a.name)} — ${formatPreis(a.preis * a.menge)}</li>`)
|
||||
.join("");
|
||||
return `
|
||||
<div style="font-family: sans-serif; color: #1a1a1a; line-height: 1.6;">
|
||||
<h2 style="margin: 0 0 0.5em;">🩵 Neue Bestellung ${escapeHtmlFuerEmail(bestellnummer)}</h2>
|
||||
<p><strong>Von:</strong> ${escapeHtmlFuerEmail(name)} (${escapeHtmlFuerEmail(email)})<br />
|
||||
<strong>Land:</strong> ${escapeHtmlFuerEmail(LAND_NAME[land] || land)}</p>
|
||||
<p><strong>Artikel:</strong></p>
|
||||
<ul>${artikelZeilen}</ul>
|
||||
<p><strong>Summe: ${formatPreis(summe)}</strong></p>
|
||||
<p><a href="https://vans-diy-bastelbedarf.pages.dev/verwaltung/">→ Zur Bestellverwaltung</a></p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function findeGutschein(code) {
|
||||
const normalisiert = String(code || "").trim().toUpperCase();
|
||||
if (!normalisiert) return null;
|
||||
return gutscheine.find((g) => String(g.code || "").toUpperCase() === normalisiert) || null;
|
||||
}
|
||||
|
||||
export async function onRequestPost(context) {
|
||||
const { request, env } = context;
|
||||
@@ -67,6 +109,30 @@ export async function onRequestPost(context) {
|
||||
const versandkosten = Number.isFinite(Number(body.versandkosten)) ? Number(body.versandkosten) : 0;
|
||||
const rabattGesamt = Number.isFinite(Number(body.rabattGesamt)) ? Number(body.rabattGesamt) : 0;
|
||||
|
||||
// ── "Nur einmal pro Person" durchsetzen — unabhängig vom Browser/Gerät, da hier gegen die
|
||||
// echten, gespeicherten Bestellungen geprüft wird (siehe d1/schema.sql, Spalte
|
||||
// "gutschein_code"). Stornierte Bestellungen zählen NICHT als "schon benutzt" — wurde ja
|
||||
// letztlich nicht wirksam eingelöst. ──
|
||||
const gutscheinCodeRoh = typeof body.gutscheinCode === "string" ? body.gutscheinCode.trim() : "";
|
||||
let gutscheinCodeNormalisiert = null;
|
||||
if (gutscheinCodeRoh) {
|
||||
const gutschein = findeGutschein(gutscheinCodeRoh);
|
||||
gutscheinCodeNormalisiert = gutscheinCodeRoh.toUpperCase();
|
||||
if (gutschein && gutschein.einmaligProPerson) {
|
||||
const { results } = await env.DB.prepare(
|
||||
`SELECT COUNT(*) as anzahl FROM orders WHERE gutschein_code = ? AND LOWER(kunde_email) = LOWER(?) AND status != 'storniert'`
|
||||
)
|
||||
.bind(gutscheinCodeNormalisiert, email)
|
||||
.all();
|
||||
if ((results?.[0]?.anzahl ?? 0) > 0) {
|
||||
return json(400, {
|
||||
ok: false,
|
||||
error: "Dieser Gutscheincode wurde bereits mit dieser E-Mail-Adresse eingelöst und gilt nur einmal pro Person.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
@@ -74,8 +140,8 @@ export async function onRequestPost(context) {
|
||||
`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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||
treuebonus_verwendet, abo_rabatt_verwendet, gutschein_code)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||
)
|
||||
.bind(
|
||||
now,
|
||||
@@ -93,7 +159,8 @@ export async function onRequestPost(context) {
|
||||
rabattGesamt,
|
||||
summe,
|
||||
body.treuebonusVerwendet ? 1 : 0,
|
||||
body.aboRabattVerwendet ? 1 : 0
|
||||
body.aboRabattVerwendet ? 1 : 0,
|
||||
gutscheinCodeNormalisiert
|
||||
)
|
||||
.run();
|
||||
|
||||
@@ -113,6 +180,20 @@ export async function onRequestPost(context) {
|
||||
);
|
||||
await env.DB.batch(itemStmts);
|
||||
|
||||
// E-Mail-Benachrichtigung an VanVan/qciga — bewusst NICHT abgewartet (context.waitUntil
|
||||
// lässt den Versand im Hintergrund fertig laufen, ohne die Antwort an die Kundschaft zu
|
||||
// verzögern) und bewusst NICHT fehlerkritisch: schlägt der Versand fehl (z.B. weil noch kein
|
||||
// RESEND_API_KEY hinterlegt ist), bleibt die Bestellung trotzdem ganz normal gespeichert und
|
||||
// in /verwaltung/ sichtbar — nur die Benachrichtigung fehlt dann eben.
|
||||
context.waitUntil(
|
||||
sendeEmail(env, {
|
||||
to: BENACHRICHTIGUNGS_EMAIL,
|
||||
subject: `🩵 Neue Bestellung ${bestellnummer}`,
|
||||
html: bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe }),
|
||||
text: `Neue Bestellung ${bestellnummer} von ${name} (${email}), ${artikel.length} Artikel, Summe ${formatPreis(summe)}. Details: https://vans-diy-bastelbedarf.pages.dev/verwaltung/`,
|
||||
})
|
||||
);
|
||||
|
||||
return json(201, { ok: true, id: orderId, bestellnummer });
|
||||
} catch (err) {
|
||||
return json(500, { ok: false, error: "Bestellung konnte nicht gespeichert werden." });
|
||||
|
||||
@@ -307,6 +307,11 @@ collections:
|
||||
- { label: "Rabatt in Prozent (z.B. 10 für 10%)", name: prozent, widget: number, value_type: int, required: false }
|
||||
- { label: "ODER: Rabatt als fester Betrag (€)", name: betrag, widget: number, value_type: float, step: 0.01, required: false }
|
||||
- { label: "Gültig bis (leer = unbegrenzt)", name: bis, widget: datetime, format: "YYYY-MM-DD", date_format: "YYYY-MM-DD", time_format: false, required: false }
|
||||
- label: "Nur einmal pro Person einlösbar?"
|
||||
name: einmaligProPerson
|
||||
widget: boolean
|
||||
default: false
|
||||
hint: "Ja = derselbe Code kann nicht zweimal mit derselben E-Mail-Adresse bestellt werden (wird beim Bestellabschluss automatisch geprüft). Nein (Standard) = beliebig oft nutzbar, wie bisher."
|
||||
|
||||
- name: partnercodes
|
||||
label: "🤝 Partner-Rabattcodes"
|
||||
|
||||
@@ -9,6 +9,12 @@ export interface Gutschein {
|
||||
prozent?: number;
|
||||
betrag?: number;
|
||||
bis?: string; // ISO-Datum, optional
|
||||
/** true = der Code gilt pro Person (genauer: pro E-Mail-Adresse) nur einmal — wird beim
|
||||
* Abschicken der Bestellung serverseitig geprüft (siehe functions/api/orders.js, Spalte
|
||||
* "gutschein_code" in der orders-Tabelle), kann also NICHT durch Löschen von localStorage/
|
||||
* einen anderen Browser umgangen werden. Ohne dieses Flag (Standard) bleibt ein Code
|
||||
* unbegrenzt mehrfach nutzbar, wie bisher. */
|
||||
einmaligProPerson?: boolean;
|
||||
}
|
||||
|
||||
const gutscheine = (gutscheineRaw as { codes: Gutschein[] }).codes ?? [];
|
||||
|
||||
@@ -377,6 +377,7 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
summe: currentTotal,
|
||||
treuebonusVerwendet: treuebonusDiscount() > 0,
|
||||
aboRabattVerwendet: aboDiscount(subtotal) > 0,
|
||||
gutscheinCode: appliedCoupon()?.code ?? null,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -387,6 +387,7 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
summe: currentTotal,
|
||||
treuebonusVerwendet: treuebonusDiscount() > 0,
|
||||
aboRabattVerwendet: aboDiscount(subtotal) > 0,
|
||||
gutscheinCode: appliedCoupon()?.code ?? null,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -377,6 +377,7 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
summe: currentTotal,
|
||||
treuebonusVerwendet: treuebonusDiscount() > 0,
|
||||
aboRabattVerwendet: aboDiscount(subtotal) > 0,
|
||||
gutscheinCode: appliedCoupon()?.code ?? null,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -377,6 +377,7 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../
|
||||
summe: currentTotal,
|
||||
treuebonusVerwendet: treuebonusDiscount() > 0,
|
||||
aboRabattVerwendet: aboDiscount(subtotal) > 0,
|
||||
gutscheinCode: appliedCoupon()?.code ?? null,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user