v2.1: Gutschein-/Rabatt-Engine + editierbare gebrandete 404

Feature 1 — Rabatt-Engine (store-sqlite.js):
- Tabellen discounts + discount_redemptions; orders um discount_code/discount_cents erweitert.
- Helper: getDiscountByCode, listDiscounts, create/update/deleteDiscount,
  validateDiscount (Zeitplan/Mindestwert/Limits/pro-Kunde), bestAutoDiscount, redeemDiscount.
- Seed: WILLKOMMEN10, NAEHEN5, GRATISVERSAND (geplant), AUTO15AB75 (auto).
- Checkout: /api/discount (serverseitige Subtotal-Berechnung) + /api/checkout re-validiert,
  wendet Rabatt/Gratisversand an, speichert + redeemt, auto-Discount-Fallback, Stripe-Coupon.
- Cart/Checkout-UI mit Code-Feld + Einlösen; Rabattzeile in Order-Detail + Erfolgsseite.
- Admin "Rabatte" (owner+redaktion) mit Status-Badges + Editor (Zufallscode, Typ-abh. Wertfeld).
- Popups: Typ discount zeigt Code + Kopieren-Button; Stile modal/slidein/bar (CSS ergaenzt).

Feature 2 — 404:
- src/pages/404.astro nutzt Base + BlockRenderer, laedt System-Seite slug 404.
- ensureSystemPages() legt 404 idempotent bei jedem Boot an (INSERT OR IGNORE, flache Bloecke).
- 404/system erscheint in Admin "Inhalte" und oeffnet im Block-Editor.

API/MCP: discounts in /api/admin/* (CRUD), Manifest + ai-admin.txt ergaenzt
(inkl. Hinweis: Block-Objekte sind flach); MCP list/upsert/delete_discount.
README + Versionen (2.1.0) aktualisiert.
This commit is contained in:
2026-06-17 15:12:07 +00:00
parent aec179db36
commit 430fa718fa
21 changed files with 572 additions and 52 deletions
+13 -2
View File
@@ -19,7 +19,8 @@ export const RESOURCES = {
products: { rw: true, fields: ['slug', 'name', 'shortName', 'priceCents', 'category', 'sizes[]', 'images[]', 'cardImage', 'badge', 'stock', 'material', 'features[]', 'featured', 'sort', 'desc', 'metafields{}'] },
pages: { rw: true, fields: ['slug', 'title', 'body', 'type(content|legal)', 'active', 'sort', 'blocks[]'] },
slides: { rw: true, fields: ['image', 'headline', 'subline', 'link', 'sort', 'active'] },
popups: { rw: true, fields: ['title', 'type', 'headline', 'body', 'image', 'cta_text', 'cta_url', 'trigger', 'trigger_value', 'target_path', 'freq', 'active', 'sort'] },
popups: { rw: true, fields: ['title', 'type(newsletter|discount|announcement|exit)', 'headline', 'body', 'image', 'cta_text', 'cta_url', 'trigger', 'trigger_value', 'target_path', 'freq', 'active', 'sort', 'style(modal|slidein|bar)', 'discount_id'] },
discounts: { rw: true, fields: ['code', 'title', 'type(percent|fixed|freeshipping)', 'value', 'min_order_cents', 'starts_at', 'expires_at', 'max_uses', 'used_count', 'max_per_customer', 'active', 'secret', 'auto'] },
settings: { rw: true, fields: ['key/value-Map (shop_name, brand_accent, currency, free_shipping_cents, …)'] },
orders: { rw: false, fields: ['number', 'email', 'customer_name', 'status', 'total_cents', 'items[]', 'address', 'created_at'] },
customers: { rw: false, fields: ['name', 'email', 'city', 'orders_count', 'total_spent_cents', 'created_at'] },
@@ -31,6 +32,7 @@ export function listResource(name) {
case 'pages': return store.listPages();
case 'slides': return store.listSlides();
case 'popups': return store.listPopups();
case 'discounts': return store.listDiscounts();
case 'orders': return store.listOrders();
case 'customers': return store.listCustomers();
case 'settings': return store.getSettings();
@@ -43,6 +45,7 @@ export function getResource(name, id) {
case 'pages': return /^\d+$/.test(String(id)) ? store.getPageById(id) : store.getPageBySlug(id);
case 'slides': return store.getSlideById(id);
case 'popups': return store.getPopupById(id);
case 'discounts': return store.getDiscountById(id);
case 'orders': return store.getOrderById(id);
case 'customers': return store.getCustomerById(id);
default: return null;
@@ -69,6 +72,11 @@ export function upsertResource(name, body) {
if (body.id) { store.updatePopup(body.id, body); return store.getPopupById(body.id); }
const id = store.createPopup(body); return store.getPopupById(id);
}
if (name === 'discounts') {
if (body.id) { store.updateDiscount(body.id, body); return store.getDiscountById(body.id); }
if (body.code) { const ex = store.getDiscountByCode(body.code); if (ex) { store.updateDiscount(ex.id, { ...ex, ...body }); return store.getDiscountById(ex.id); } }
const id = store.createDiscount(body); return store.getDiscountById(id);
}
if (name === 'settings') {
const entries = body && typeof body === 'object' ? Object.entries(body) : [];
for (const [k, v] of entries) store.setSetting(k, v);
@@ -83,6 +91,7 @@ export function deleteResource(name, id) {
case 'pages': store.deletePage(id); return true;
case 'slides': store.deleteSlide(id); return true;
case 'popups': store.deletePopup(id); return true;
case 'discounts': store.deleteDiscount(id); return true;
default: throw new Error('Ressource nicht löschbar: ' + name);
}
}
@@ -105,7 +114,7 @@ export function manifest(origin) {
ep.push({ method: 'POST', path: '/api/admin/pages/{id}/blocks', desc: 'Block-Array einer Seite setzen' });
return {
name: 'hd-commerce Admin API',
version: '2.0.0',
version: '2.1.0',
auth: 'Authorization: Bearer <HDC_API_TOKEN>',
base_url: origin || '',
resources: RESOURCES,
@@ -116,6 +125,8 @@ export function manifest(origin) {
'orders und customers sind nur lesbar.',
'settings ist eine Key/Value-Map; POST mit beliebigen Keys aktualisiert sie.',
'pages.blocks ist ein Array von Blöcken (siehe block_types) für den Visual-Builder.',
'Block-Objekte sind FLACH: { type, <feldname>: ... } — NICHT unter einem data-Schlüssel verschachtelt.',
'discounts.value: bei percent 1100, bei fixed in Cent, bei freeshipping ignoriert. Codes werden case-insensitiv geprüft.',
],
};
}
+2 -2
View File
@@ -75,8 +75,8 @@ export function currentUser(request) {
// --- Rollen-Gate ---
// owner: alles · redaktion: Produkte/Inhalte/Marketing · versand: nur Bestellungen
const ROLE_SECTIONS = {
owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'inhalte', 'einstellungen', 'nutzer', 'audit'],
redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'analytics'],
owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'rabatte', 'inhalte', 'einstellungen', 'nutzer', 'audit'],
redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'rabatte', 'analytics'],
versand: ['bestellungen'],
};
export function canAccess(role, section) {
+158 -9
View File
@@ -71,11 +71,26 @@ CREATE TABLE IF NOT EXISTS events (
CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT, url TEXT, mime TEXT, size INTEGER, created_at TEXT
);
CREATE TABLE IF NOT EXISTS discounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT UNIQUE NOT NULL, title TEXT DEFAULT '',
type TEXT DEFAULT 'percent', value INTEGER DEFAULT 0, min_order_cents INTEGER DEFAULT 0,
starts_at TEXT, expires_at TEXT, max_uses INTEGER, used_count INTEGER DEFAULT 0,
max_per_customer INTEGER, active INTEGER DEFAULT 1, secret INTEGER DEFAULT 0, auto INTEGER DEFAULT 0,
created_at TEXT
);
CREATE TABLE IF NOT EXISTS discount_redemptions (
id INTEGER PRIMARY KEY AUTOINCREMENT, discount_id INTEGER, code TEXT, email TEXT,
order_id INTEGER, amount_cents INTEGER DEFAULT 0, created_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at);
CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit(created_at);
`);
ensureColumn('pages', 'blocks', "blocks TEXT DEFAULT '[]'");
ensureColumn('orders', 'discount_code', "discount_code TEXT DEFAULT ''");
ensureColumn('orders', 'discount_cents', "discount_cents INTEGER DEFAULT 0");
ensureColumn('popups', 'style', "style TEXT DEFAULT 'modal'");
ensureColumn('popups', 'discount_id', "discount_id INTEGER");
// ---------- mappers ----------
const P = (r) => r && ({ ...r, sizes: JSON.parse(r.sizes || '[]'), images: JSON.parse(r.images || '[]'), features: JSON.parse(r.features || '[]'), metafields: JSON.parse(r.metafields || '{}'), featured: !!r.featured });
@@ -117,6 +132,7 @@ function seedIfEmpty() {
const now = new Date().toISOString();
SEED_POPUPS.forEach(p => ip.run({ ...p, created_at: now }));
}
if (db.prepare('SELECT COUNT(*) c FROM discounts').get().c === 0) seedDiscounts();
// seed some demo analytics events so the analytics dashboard is not empty
if (db.prepare('SELECT COUNT(*) c FROM events').get().c === 0) seedEvents();
}
@@ -165,6 +181,130 @@ function seedEvents() {
}
seedIfEmpty();
// ---------- discounts: seed + system pages ----------
function seedDiscounts() {
const now = new Date().toISOString();
const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1);
const rows = [
{ code: 'WILLKOMMEN10', title: '10 % Willkommensrabatt', type: 'percent', value: 10, min_order_cents: 0, starts_at: null, expires_at: null, max_uses: null, max_per_customer: 1, active: 1, secret: 0, auto: 0 },
{ code: 'NAEHEN5', title: '5 % Aktion', type: 'percent', value: 5, min_order_cents: 0, starts_at: null, expires_at: null, max_uses: 5, max_per_customer: null, active: 1, secret: 0, auto: 0 },
{ code: 'GRATISVERSAND', title: 'Gratisversand ab 30 €', type: 'freeshipping', value: 0, min_order_cents: 3000, starts_at: tomorrow.toISOString(), expires_at: null, max_uses: null, max_per_customer: null, active: 1, secret: 0, auto: 0 },
{ code: 'AUTO15AB75', title: 'Automatisch 15 € ab 75 €', type: 'fixed', value: 1500, min_order_cents: 7500, starts_at: null, expires_at: null, max_uses: null, max_per_customer: null, active: 1, secret: 0, auto: 1 },
];
const ins = db.prepare(`INSERT OR IGNORE INTO discounts (code,title,type,value,min_order_cents,starts_at,expires_at,max_uses,used_count,max_per_customer,active,secret,auto,created_at)
VALUES (@code,@title,@type,@value,@min_order_cents,@starts_at,@expires_at,@max_uses,0,@max_per_customer,@active,@secret,@auto,@created_at)`);
const tx = db.transaction(() => rows.forEach(r => ins.run({ ...r, code: r.code.toUpperCase(), created_at: now })));
tx();
}
// Idempotenter Boot-Schritt: gebrandete System-Seiten sicherstellen (404).
export function ensureSystemPages() {
const blocks404 = JSON.stringify([
{ type: 'hero', headline: 'Seite nicht gefunden', subline: 'Diese Seite gibt es nicht oder sie wurde verschoben.', cta_text: 'Zur Startseite', cta_url: '/', align: 'center' },
{ type: 'productgrid', headline: 'Vielleicht interessiert dich das', source: 'featured', limit: 4 },
]);
try {
db.prepare(`INSERT OR IGNORE INTO pages (slug,title,body,type,active,sort,blocks)
VALUES ('404','Seite nicht gefunden','','system',1,200,?)`).run(blocks404);
} catch {}
}
ensureSystemPages();
// ---------- discounts: mapper + CRUD ----------
const D = (r) => r && ({ ...r, active: !!r.active, secret: !!r.secret, auto: !!r.auto });
export const listDiscounts = () => db.prepare('SELECT * FROM discounts ORDER BY id DESC').all().map(D);
export const getDiscountById = (id) => D(db.prepare('SELECT * FROM discounts WHERE id=?').get(Number(id)));
export const getDiscountByCode = (code) => D(db.prepare('SELECT * FROM discounts WHERE UPPER(code)=UPPER(?)').get(String(code || '').trim()));
const DISCOUNT_TYPES = ['percent', 'fixed', 'freeshipping'];
function normDiscount(d) {
let type = DISCOUNT_TYPES.includes(d.type) ? d.type : 'percent';
let value = Math.round(Number(d.value) || 0);
if (type === 'percent') value = Math.max(1, Math.min(100, value || 1));
if (type === 'freeshipping') value = 0;
const numOrNull = (v) => (v === '' || v == null) ? null : Math.round(Number(v));
const txtOrNull = (v) => (v === '' || v == null) ? null : String(v);
return {
code: String(d.code || '').trim().toUpperCase(),
title: d.title || '',
type, value,
min_order_cents: Math.max(0, Math.round(Number(d.min_order_cents) || 0)),
starts_at: txtOrNull(d.starts_at),
expires_at: txtOrNull(d.expires_at),
max_uses: numOrNull(d.max_uses),
max_per_customer: numOrNull(d.max_per_customer),
active: d.active ? 1 : 0,
secret: d.secret ? 1 : 0,
auto: d.auto ? 1 : 0,
};
}
export function createDiscount(d) {
const n = normDiscount(d);
if (!n.code) throw new Error('Code erforderlich');
const r = db.prepare(`INSERT INTO discounts (code,title,type,value,min_order_cents,starts_at,expires_at,max_uses,used_count,max_per_customer,active,secret,auto,created_at)
VALUES (@code,@title,@type,@value,@min_order_cents,@starts_at,@expires_at,@max_uses,0,@max_per_customer,@active,@secret,@auto,@created_at)`)
.run({ ...n, created_at: new Date().toISOString() });
return r.lastInsertRowid;
}
export function updateDiscount(id, d) {
const cur = db.prepare('SELECT * FROM discounts WHERE id=?').get(Number(id));
if (!cur) return id;
const n = normDiscount({ ...cur, ...d });
db.prepare(`UPDATE discounts SET code=@code,title=@title,type=@type,value=@value,min_order_cents=@min_order_cents,
starts_at=@starts_at,expires_at=@expires_at,max_uses=@max_uses,max_per_customer=@max_per_customer,
active=@active,secret=@secret,auto=@auto WHERE id=@id`).run({ ...n, id: Number(id) });
return id;
}
export const deleteDiscount = (id) => db.prepare('DELETE FROM discounts WHERE id=?').run(Number(id));
function discountAmount(d, subtotalCents) {
if (d.type === 'percent') return Math.round(subtotalCents * d.value / 100);
if (d.type === 'fixed') return Math.min(d.value, subtotalCents);
return 0; // freeshipping
}
// Prüft einen Code gegen Warenkorb-Zwischensumme; gibt strukturiertes Ergebnis.
export function validateDiscount(code, subtotalCents, email) {
const sub = Math.max(0, Math.round(Number(subtotalCents) || 0));
const d = getDiscountByCode(code);
if (!d || !d.active) return { ok: false, reason: 'Code ungültig' };
const now = Date.now();
if (d.starts_at && now < new Date(d.starts_at).getTime()) return { ok: false, reason: 'noch nicht gültig' };
if (d.expires_at && now > new Date(d.expires_at).getTime()) return { ok: false, reason: 'abgelaufen' };
if (sub < (d.min_order_cents || 0)) return { ok: false, reason: 'Mindestbestellwert ' + formatPrice(d.min_order_cents) + ' nicht erreicht' };
if (d.max_uses != null && d.used_count >= d.max_uses) return { ok: false, reason: 'aufgebraucht' };
if (d.max_per_customer != null && email) {
const used = db.prepare('SELECT COUNT(*) c FROM discount_redemptions WHERE discount_id=? AND LOWER(email)=LOWER(?)').get(d.id, String(email)).c;
if (used >= d.max_per_customer) return { ok: false, reason: 'pro Kunde aufgebraucht' };
}
const amountCents = discountAmount(d, sub);
return { ok: true, id: d.id, code: d.code, title: d.title, type: d.type, value: d.value, amountCents, freeShipping: d.type === 'freeshipping' };
}
// Bestes aktives auto-Discount, dessen Bedingungen erfüllt sind.
export function bestAutoDiscount(subtotalCents) {
const sub = Math.max(0, Math.round(Number(subtotalCents) || 0));
const rows = db.prepare('SELECT * FROM discounts WHERE active=1 AND auto=1').all().map(D);
let best = null;
for (const d of rows) {
const v = validateDiscount(d.code, sub);
if (!v.ok) continue;
if (!best || v.amountCents > best.amountCents) best = v;
}
return best;
}
export function redeemDiscount(discountId, code, email, orderId, amountCents) {
try {
db.prepare('INSERT INTO discount_redemptions (discount_id,code,email,order_id,amount_cents,created_at) VALUES (?,?,?,?,?,?)')
.run(Number(discountId), String(code || ''), String(email || ''), orderId != null ? Number(orderId) : null, Math.round(Number(amountCents) || 0), new Date().toISOString());
db.prepare('UPDATE discounts SET used_count = used_count + 1 WHERE id=?').run(Number(discountId));
} catch {}
return true;
}
export const listRedemptions = (discountId) => db.prepare('SELECT * FROM discount_redemptions WHERE discount_id=? ORDER BY id DESC').all(Number(discountId));
// ---------- settings ----------
export function getSettings() {
const rows = db.prepare('SELECT key,value FROM settings').all();
@@ -220,12 +360,12 @@ export const deleteProduct = (id) => db.prepare('DELETE FROM products WHERE id=?
export const listOrders = () => db.prepare('SELECT * FROM orders ORDER BY datetime(created_at) DESC, id DESC').all().map(O);
export const getOrderById = (id) => O(db.prepare('SELECT * FROM orders WHERE id=?').get(Number(id)));
export const getOrderByNumber = (num) => O(db.prepare('SELECT * FROM orders WHERE number=?').get(num));
export function createOrder({ email, customer_name, items, total_cents, status = 'pending', address = '' }) {
export function createOrder({ email, customer_name, items, total_cents, status = 'pending', address = '', discount_code = '', discount_cents = 0 }) {
const m = db.prepare("SELECT MAX(CAST(substr(number,5) AS INTEGER)) m FROM orders").get().m || 1000;
const number = 'BNK-' + (m + 1);
const now = new Date().toISOString();
const r = db.prepare('INSERT INTO orders (number,email,customer_name,status,total_cents,items,address,created_at) VALUES (?,?,?,?,?,?,?,?)')
.run(number, email || '', customer_name || '', status, total_cents || 0, JSON.stringify(items || []), address || '', now);
const r = db.prepare('INSERT INTO orders (number,email,customer_name,status,total_cents,items,address,created_at,discount_code,discount_cents) VALUES (?,?,?,?,?,?,?,?,?,?)')
.run(number, email || '', customer_name || '', status, total_cents || 0, JSON.stringify(items || []), address || '', now, discount_code || '', Math.round(Number(discount_cents) || 0));
if (email) {
db.prepare('INSERT OR IGNORE INTO customers (name,email,city,created_at) VALUES (?,?,?,?)').run(customer_name || '', email, '', now);
}
@@ -298,18 +438,27 @@ export const listPopups = () => db.prepare('SELECT * FROM popups ORDER BY sort,
export const getPopupById = (id) => db.prepare('SELECT * FROM popups WHERE id=?').get(Number(id));
export function popupsForPath(path) {
return db.prepare('SELECT * FROM popups WHERE active=1 ORDER BY sort, id').all()
.filter(p => p.target_path === '*' || p.target_path === path || (p.target_path && p.target_path.endsWith('*') && path.startsWith(p.target_path.slice(0, -1))));
.filter(p => p.target_path === '*' || p.target_path === path || (p.target_path && p.target_path.endsWith('*') && path.startsWith(p.target_path.slice(0, -1))))
.map(p => {
if (p.discount_id) {
const d = db.prepare('SELECT code FROM discounts WHERE id=?').get(p.discount_id);
return { ...p, discount_code: d ? d.code : '' };
}
return p;
});
}
export function createPopup(d) {
return db.prepare(`INSERT INTO popups (title,type,headline,body,image,cta_text,cta_url,trigger,trigger_value,target_path,freq,active,sort,created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
return db.prepare(`INSERT INTO popups (title,type,headline,body,image,cta_text,cta_url,trigger,trigger_value,target_path,freq,active,sort,style,discount_id,created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
.run(d.title || '', d.type || 'newsletter', d.headline || '', d.body || '', d.image || '', d.cta_text || '', d.cta_url || '',
d.trigger || 'delay', Number(d.trigger_value) || 0, d.target_path || '/', d.freq || 'session', d.active ? 1 : 0, Number(d.sort) || 99, new Date().toISOString()).lastInsertRowid;
d.trigger || 'delay', Number(d.trigger_value) || 0, d.target_path || '/', d.freq || 'session', d.active ? 1 : 0, Number(d.sort) || 99,
d.style || 'modal', (d.discount_id === '' || d.discount_id == null) ? null : Number(d.discount_id), new Date().toISOString()).lastInsertRowid;
}
export function updatePopup(id, d) {
db.prepare(`UPDATE popups SET title=?,type=?,headline=?,body=?,image=?,cta_text=?,cta_url=?,trigger=?,trigger_value=?,target_path=?,freq=?,active=?,sort=? WHERE id=?`)
db.prepare(`UPDATE popups SET title=?,type=?,headline=?,body=?,image=?,cta_text=?,cta_url=?,trigger=?,trigger_value=?,target_path=?,freq=?,active=?,sort=?,style=?,discount_id=? WHERE id=?`)
.run(d.title || '', d.type || 'newsletter', d.headline || '', d.body || '', d.image || '', d.cta_text || '', d.cta_url || '',
d.trigger || 'delay', Number(d.trigger_value) || 0, d.target_path || '/', d.freq || 'session', d.active ? 1 : 0, Number(d.sort) || 99, Number(id));
d.trigger || 'delay', Number(d.trigger_value) || 0, d.target_path || '/', d.freq || 'session', d.active ? 1 : 0, Number(d.sort) || 99,
d.style || 'modal', (d.discount_id === '' || d.discount_id == null) ? null : Number(d.discount_id), Number(id));
return id;
}
export const deletePopup = (id) => db.prepare('DELETE FROM popups WHERE id=?').run(Number(id));