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:
@@ -33,6 +33,12 @@ const statuses = [['pending', 'Offen'], ['fulfilled', 'Erfüllt'], ['cancelled',
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{order.discount_cents > 0 && (
|
||||
<div class="s-card-pad" style="display:flex;justify-content:space-between;color:var(--accent);font-size:14px;border-top:1px solid var(--s-border)"><span>Rabatt{order.discount_code ? ` (${order.discount_code})` : ''}</span><span>−{formatPrice(order.discount_cents)}</span></div>
|
||||
)}
|
||||
{(order.discount_cents === 0 && order.discount_code) && (
|
||||
<div class="s-card-pad" style="display:flex;justify-content:space-between;color:var(--accent);font-size:14px;border-top:1px solid var(--s-border)"><span>Gutschein ({order.discount_code})</span><span>Gratisversand</span></div>
|
||||
)}
|
||||
<div class="s-card-pad" style="display:flex;justify-content:space-between;font-weight:700;font-size:16px;border-top:1px solid var(--s-border)"><span>Gesamt</span><span>{formatPrice(order.total_cents)}</span></div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -59,8 +59,8 @@ const tabs = [['pages', 'Seiten & Rechtstexte'], ['slider', 'Slider'], ['media',
|
||||
{pages.map((p) => (
|
||||
<tr>
|
||||
<td><b>{p.title}</b></td>
|
||||
<td class="s-muted">/seite/{p.slug}</td>
|
||||
<td><span class={`s-badge ${p.type === 'legal' ? 'blue' : 'gray'}`}>{p.type === 'legal' ? 'Rechtstext' : 'Inhalt'}</span></td>
|
||||
<td class="s-muted">{p.type === 'system' ? `/${p.slug}` : `/seite/${p.slug}`}</td>
|
||||
<td><span class={`s-badge ${p.type === 'legal' ? 'blue' : (p.type === 'system' ? 'amber' : 'gray')}`}>{p.type === 'legal' ? 'Rechtstext' : (p.type === 'system' ? 'System' : 'Inhalt')}</span></td>
|
||||
<td>{p.active ? <span class="s-badge green">Aktiv</span> : <span class="s-badge gray">Aus</span>}</td>
|
||||
<td class="num">
|
||||
<a class="s-btn s-btn-sm s-btn-primary" href={`${base}/inhalte/editor/${p.id}`}>Editor</a>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Admin from '../../../layouts/Admin.astro';
|
||||
import { adminBase, currentUser } from '../../../lib/auth.js';
|
||||
const base = adminBase();
|
||||
import { listPopups, createPopup, updatePopup, deletePopup, getPopupById, setSetting, getSettings, recordAudit } from '../../../lib/store.js';
|
||||
import { listPopups, createPopup, updatePopup, deletePopup, getPopupById, setSetting, getSettings, recordAudit, listDiscounts } from '../../../lib/store.js';
|
||||
|
||||
let flash = '';
|
||||
if (Astro.request.method === 'POST') {
|
||||
@@ -23,6 +23,8 @@ if (Astro.request.method === 'POST') {
|
||||
trigger: f.get('trigger') || 'delay', trigger_value: parseInt(String(f.get('trigger_value') || '0')) || 0,
|
||||
target_path: f.get('target_path') || '/', freq: f.get('freq') || 'session', active: f.get('active') === 'on',
|
||||
sort: parseInt(String(f.get('sort') || '99')) || 99,
|
||||
style: f.get('style') || 'modal',
|
||||
discount_id: f.get('discount_id') || '',
|
||||
};
|
||||
const editId = f.get('id');
|
||||
if (editId) { updatePopup(editId, data); recordAudit({ user: _me?.email, action: 'update', entity: 'popup', entity_id: String(editId) }); flash = 'Popup gespeichert.'; }
|
||||
@@ -34,7 +36,9 @@ const settings = getSettings();
|
||||
const popups = listPopups();
|
||||
const editId = new URL(Astro.request.url).searchParams.get('edit');
|
||||
const editing = editId ? getPopupById(editId) : null;
|
||||
const e = editing || { id: '', title: '', type: 'newsletter', headline: '', body: '', image: '', cta_text: '', cta_url: '', trigger: 'exit', trigger_value: 0, target_path: '/', freq: 'days7', active: 1, sort: 1 };
|
||||
const e = editing || { id: '', title: '', type: 'newsletter', headline: '', body: '', image: '', cta_text: '', cta_url: '', trigger: 'exit', trigger_value: 0, target_path: '/', freq: 'days7', active: 1, sort: 1, style: 'modal', discount_id: '' };
|
||||
const discounts = listDiscounts();
|
||||
const styles = [['modal', 'Modal (zentral)'], ['slidein', 'Slide-in (unten rechts)'], ['bar', 'Balken (oben)']];
|
||||
const triggers = [['delay', 'Verzögerung (Sek.)'], ['scroll', 'Scroll-Tiefe (%)'], ['exit', 'Exit-Intent']];
|
||||
const freqs = [['session', 'Pro Session'], ['days7', 'Alle 7 Tage'], ['always', 'Immer']];
|
||||
const types = [['newsletter', 'Newsletter'], ['discount', 'Rabatt'], ['announcement', 'Ankündigung'], ['exit', 'Exit-Angebot']];
|
||||
@@ -87,6 +91,10 @@ const types = [['newsletter', 'Newsletter'], ['discount', 'Rabatt'], ['announcem
|
||||
{editing && <input type="hidden" name="id" value={e.id} />}
|
||||
<div class="s-field"><label class="s-label">Interner Titel</label><input class="s-input" name="title" value={e.title} required /></div>
|
||||
<div class="s-field"><label class="s-label">Typ</label><select class="s-select" name="type">{types.map(([v, l]) => (<option value={v} selected={e.type === v}>{l}</option>))}</select></div>
|
||||
<div class="s-form-grid">
|
||||
<div class="s-field"><label class="s-label">Darstellung</label><select class="s-select" name="style">{styles.map(([v, l]) => (<option value={v} selected={(e.style || 'modal') === v}>{l}</option>))}</select></div>
|
||||
<div class="s-field"><label class="s-label">Rabatt-Code (bei Typ „Rabatt")</label><select class="s-select" name="discount_id"><option value="">— keiner —</option>{discounts.map((d) => (<option value={d.id} selected={String(e.discount_id || '') === String(d.id)}>{d.code}{d.secret ? ' (geheim)' : ''}</option>))}</select></div>
|
||||
</div>
|
||||
<div class="s-field"><label class="s-label">Headline</label><input class="s-input" name="headline" value={e.headline} /></div>
|
||||
<div class="s-field"><label class="s-label">Text</label><textarea class="s-textarea" name="body">{e.body}</textarea></div>
|
||||
<div class="s-field"><label class="s-label">Bild-URL (optional)</label><input class="s-input" name="image" value={e.image} /></div>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
import Admin from '../../../layouts/Admin.astro';
|
||||
import { adminBase, currentUser } from '../../../lib/auth.js';
|
||||
const base = adminBase();
|
||||
import { listDiscounts, getDiscountById, createDiscount, updateDiscount, deleteDiscount, formatPrice, recordAudit } from '../../../lib/store.js';
|
||||
|
||||
let flash = '';
|
||||
if (Astro.request.method === 'POST') {
|
||||
const f = await Astro.request.formData();
|
||||
const _me = currentUser(Astro.request);
|
||||
const action = f.get('_action');
|
||||
if (action === 'delete') {
|
||||
deleteDiscount(f.get('id'));
|
||||
recordAudit({ user: _me?.email, action: 'delete', entity: 'discount', entity_id: String(f.get('id')) });
|
||||
return Astro.redirect(base + '/rabatte');
|
||||
} else if (action === 'save') {
|
||||
const data = {
|
||||
code: f.get('code') || '', title: f.get('title') || '', type: f.get('type') || 'percent',
|
||||
// Bei Fixbetrag gibt das UI Euro ein -> in Cent umrechnen. Bei Prozent/Gratisversand 1:1.
|
||||
value: (f.get('type') === 'fixed')
|
||||
? Math.round((parseFloat(String(f.get('value') || '0')) || 0) * 100)
|
||||
: (parseInt(String(f.get('value') || '0')) || 0),
|
||||
min_order_cents: Math.round((parseFloat(String(f.get('min_order') || '0')) || 0) * 100),
|
||||
starts_at: f.get('starts_at') ? new Date(String(f.get('starts_at'))).toISOString() : '',
|
||||
expires_at: f.get('expires_at') ? new Date(String(f.get('expires_at'))).toISOString() : '',
|
||||
max_uses: f.get('max_uses') === '' ? '' : parseInt(String(f.get('max_uses'))),
|
||||
max_per_customer: f.get('max_per_customer') === '' ? '' : parseInt(String(f.get('max_per_customer'))),
|
||||
active: f.get('active') === 'on', secret: f.get('secret') === 'on', auto: f.get('auto') === 'on',
|
||||
};
|
||||
const id = f.get('id');
|
||||
try {
|
||||
if (id) { updateDiscount(id, data); recordAudit({ user: _me?.email, action: 'update', entity: 'discount', entity_id: String(id) }); }
|
||||
else { const nid = createDiscount(data); recordAudit({ user: _me?.email, action: 'create', entity: 'discount', entity_id: String(nid) }); }
|
||||
return Astro.redirect(base + '/rabatte?saved=1');
|
||||
} catch (e) { flash = 'Fehler: ' + (e && e.message || e); }
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(Astro.request.url);
|
||||
if (url.searchParams.get('saved')) flash = 'Gespeichert.';
|
||||
const editId = url.searchParams.get('edit');
|
||||
const editing = editId ? getDiscountById(editId) : null;
|
||||
const discounts = listDiscounts();
|
||||
|
||||
const e = editing || { id: '', code: '', title: '', type: 'percent', value: 10, min_order_cents: 0, starts_at: '', expires_at: '', max_uses: '', max_per_customer: '', active: 1, secret: 0, auto: 0 };
|
||||
// ISO -> datetime-local
|
||||
const dtLocal = (iso) => { if (!iso) return ''; try { const d = new Date(iso); const p = (n) => String(n).padStart(2,'0'); return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; } catch { return ''; } };
|
||||
|
||||
const now = Date.now();
|
||||
function statusOf(d) {
|
||||
if (!d.active) return ['gray', 'Inaktiv'];
|
||||
if (d.starts_at && now < new Date(d.starts_at).getTime()) return ['blue', 'Geplant'];
|
||||
if (d.expires_at && now > new Date(d.expires_at).getTime()) return ['red', 'Abgelaufen'];
|
||||
if (d.max_uses != null && d.used_count >= d.max_uses) return ['amber', 'Aufgebraucht'];
|
||||
return ['green', 'Aktiv'];
|
||||
}
|
||||
const typeLabel = { percent: 'Prozent', fixed: 'Fixbetrag', freeshipping: 'Gratisversand' };
|
||||
function valueLabel(d) {
|
||||
if (d.type === 'percent') return d.value + ' %';
|
||||
if (d.type === 'fixed') return formatPrice(d.value);
|
||||
return '—';
|
||||
}
|
||||
const types = [['percent', 'Prozent (%)'], ['fixed', 'Fixbetrag (€)'], ['freeshipping', 'Gratisversand']];
|
||||
---
|
||||
<Admin title="Rabatte" active="rabatte" crumbs={[{ label: 'Rabatte' }]}>
|
||||
<div class="s-stack">
|
||||
{flash && <div class="s-flash">✓ {flash}</div>}
|
||||
|
||||
<div class="s-two-col">
|
||||
<div class="s-card">
|
||||
<div class="s-card-head">Gutscheine & Rabatte<a class="s-link" href={base + "/rabatte"}>+ Neu</a></div>
|
||||
<div class="s-table-wrap">
|
||||
<table class="s-table">
|
||||
<thead><tr><th>Code</th><th>Typ</th><th>Wert</th><th>Verbrauch</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{discounts.length === 0 ? (<tr><td colspan="6" class="s-empty">Keine Rabatte</td></tr>) :
|
||||
discounts.map((d) => {
|
||||
const st = statusOf(d);
|
||||
return (
|
||||
<tr>
|
||||
<td><b>{d.code}</b>{d.secret && <span class="s-badge gray" style="margin-left:6px">geheim</span>}{d.auto && <span class="s-badge blue" style="margin-left:6px">auto</span>}<div class="s-muted" style="font-size:12px">{d.title}</div></td>
|
||||
<td class="s-muted">{typeLabel[d.type] || d.type}</td>
|
||||
<td class="s-muted">{valueLabel(d)}</td>
|
||||
<td class="s-muted">{d.used_count}{d.max_uses != null ? ' / ' + d.max_uses : ''}</td>
|
||||
<td><span class={`s-badge ${st[0]}`}>{st[1]}</span></td>
|
||||
<td class="num">
|
||||
<a class="s-btn s-btn-sm" href={`${base}/rabatte?edit=${d.id}`}>Bearbeiten</a>
|
||||
<form method="POST" style="display:inline" onsubmit="return confirm('Rabatt löschen?')"><input type="hidden" name="_action" value="delete" /><input type="hidden" name="id" value={d.id} /><button class="s-btn s-btn-sm s-btn-danger">Löschen</button></form>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-card s-card-pad">
|
||||
<div class="s-section-title" style="margin-bottom:12px">{editing ? 'Rabatt bearbeiten' : 'Rabatt anlegen'}</div>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="_action" value="save" />
|
||||
{editing && <input type="hidden" name="id" value={e.id} />}
|
||||
<div class="s-field"><label class="s-label">Code</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input class="s-input" id="discCodeInput" name="code" value={e.code} required style="flex:1;text-transform:uppercase" />
|
||||
<button type="button" class="s-btn" id="genCode">Zufallscode</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-field"><label class="s-label">Titel (intern)</label><input class="s-input" name="title" value={e.title} /></div>
|
||||
<div class="s-form-grid">
|
||||
<div class="s-field"><label class="s-label">Typ</label><select class="s-select" name="type" id="discType">{types.map(([v, l]) => (<option value={v} selected={e.type === v}>{l}</option>))}</select></div>
|
||||
<div class="s-field"><label class="s-label" id="valLabel">Wert</label><input class="s-input" name="value" type="number" id="discValue" value={e.type === 'fixed' ? Math.round((e.value||0)/100) : e.value} /></div>
|
||||
</div>
|
||||
<div class="s-field"><label class="s-label">Mindestbestellwert (€)</label><input class="s-input" name="min_order" type="number" step="0.01" value={((e.min_order_cents||0)/100) || ''} /></div>
|
||||
<div class="s-form-grid">
|
||||
<div class="s-field"><label class="s-label">Start (optional)</label><input class="s-input" name="starts_at" type="datetime-local" value={dtLocal(e.starts_at)} /></div>
|
||||
<div class="s-field"><label class="s-label">Ablauf (optional)</label><input class="s-input" name="expires_at" type="datetime-local" value={dtLocal(e.expires_at)} /></div>
|
||||
</div>
|
||||
<div class="s-form-grid">
|
||||
<div class="s-field"><label class="s-label">Max. Nutzungen (gesamt)</label><input class="s-input" name="max_uses" type="number" value={e.max_uses ?? ''} placeholder="∞" /></div>
|
||||
<div class="s-field"><label class="s-label">Max. pro Kunde</label><input class="s-input" name="max_per_customer" type="number" value={e.max_per_customer ?? ''} placeholder="∞" /></div>
|
||||
</div>
|
||||
<div class="s-form-grid">
|
||||
<div class="s-field"><label class="s-check"><input type="checkbox" name="active" checked={!!e.active} /> Aktiv</label></div>
|
||||
<div class="s-field"><label class="s-check"><input type="checkbox" name="secret" checked={!!e.secret} /> Geheim (nicht listbar)</label></div>
|
||||
</div>
|
||||
<div class="s-field"><label class="s-check"><input type="checkbox" name="auto" checked={!!e.auto} /> Automatisch (ohne Code-Eingabe, wenn Bedingungen erfüllt)</label></div>
|
||||
<button class="s-btn s-btn-primary" type="submit" style="width:100%;margin-top:6px">{editing ? 'Speichern' : 'Anlegen'}</button>
|
||||
{editing && <a class="s-btn" href={base + "/rabatte"} style="width:100%;justify-content:center;margin-top:8px">Abbrechen</a>}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
var typeSel = document.getElementById('discType'), valLabel = document.getElementById('valLabel'), valInput = document.getElementById('discValue');
|
||||
function syncType() {
|
||||
var t = typeSel.value;
|
||||
if (t === 'percent') { valLabel.textContent = 'Wert (%)'; valInput.disabled = false; valInput.placeholder = '1–100'; }
|
||||
else if (t === 'fixed') { valLabel.textContent = 'Wert (€)'; valInput.disabled = false; valInput.placeholder = 'z. B. 10'; }
|
||||
else { valLabel.textContent = 'Wert'; valInput.value = 0; valInput.disabled = true; }
|
||||
}
|
||||
if (typeSel) { typeSel.addEventListener('change', syncType); syncType(); }
|
||||
var gen = document.getElementById('genCode'), codeInp = document.getElementById('discCodeInput');
|
||||
if (gen && codeInp) gen.addEventListener('click', function () {
|
||||
var chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789', out = '';
|
||||
for (var i = 0; i < 8; i++) out += chars[Math.floor(Math.random() * chars.length)];
|
||||
codeInp.value = out;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</Admin>
|
||||
Reference in New Issue
Block a user