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:
@@ -10,6 +10,8 @@ Die mitgelieferte Demo-Instanz heißt **„Brittas Nähkiste"** (Kurzwaren/Nähb
|
||||
- **Admin** (Premium, „Warmth & Approachability"): Session-Login statt Browser-Basic-Auth, Rollen (Owner/Redaktion/Versand), Command-Palette (⌘K), Toasts, aufgewertetes Dashboard mit KPI-Trends, Sparkline, Aktivitäts-Feed und Schnellaktionen.
|
||||
- **Visual-Block-Builder**: Vollbild-Editor mit Block-Liste (Drag/▲▼/duplizieren/löschen), Live-Vorschau (Desktop/Mobil) und Block-Einstellungen. Block-Typen: Hero, Rich-Text, Bild, Galerie, Slider, Feature-Grid, Produkt-Grid, CTA-Banner, Abstand, Roh-HTML.
|
||||
- **KI-Editierbarkeit**: token-gesicherte Admin-JSON-API (`/api/admin/*`) plus maschinenlesbares Manifest (`/api/admin`, `/ai-admin.txt`) und ein **MCP-Server** (`mcp/`).
|
||||
- **Gutschein-/Rabatt-Engine** (v2.1): Codes vom Typ `percent` / `fixed` / `freeshipping` mit Zeitplan, Mindestbestellwert, Gesamt- und Pro-Kunde-Limit, „geheim" (nicht öffentlich listbar) und „automatisch" (greift ohne Code, wenn Bedingungen erfüllt). Admin-Bereich **Rabatte** (Owner/Redaktion) mit Status-Badges (Aktiv/Geplant/Abgelaufen/Aufgebraucht/Inaktiv); Storefront-Einlösung im Checkout über `/api/discount`; serverseitige Re-Validierung in `/api/checkout`; Stripe-Coupon-Anbindung. Popups können einen Code anzeigen (+ Kopieren-Button) — auch für gezielt verteilte geheime Codes; Popup-Stile `modal` / `slidein` / `bar`.
|
||||
- **Editierbare, gebrandete 404** (v2.1): `src/pages/404.astro` rendert die System-Seite mit Slug `404` über den Block-Builder. Wird per `ensureSystemPages()` bei jedem Boot idempotent angelegt und ist im Admin unter **Inhalte** editierbar.
|
||||
- **Engine**: synchron via `better-sqlite3` (WAL), automatisches Seeding beim ersten Start.
|
||||
- **First-Party-Analytics**: eigene `events`-Tabelle, kein externer Dienst (Session = täglich rollender Hash).
|
||||
- **Branding konfigurierbar**: Shop-Name, Akzentfarbe, Währung u. a. in einer `settings`-Tabelle.
|
||||
@@ -90,7 +92,7 @@ Das `Dockerfile` (node:22-slim) baut `better-sqlite3` nativ, legt `/data` an und
|
||||
|
||||
## Datenmodell
|
||||
|
||||
`settings`, `products`, `orders`, `customers`, `slides`, `pages` (inkl. `blocks`), `popups`, `subscribers`, `events`, `media`, `users`, `audit` — alles seed-bar und im Admin pflegbar.
|
||||
`settings`, `products`, `orders` (inkl. `discount_code` / `discount_cents`), `customers`, `slides`, `pages` (inkl. `blocks`; System-Seite `404`), `popups` (inkl. `style` / `discount_id`), `discounts`, `discount_redemptions`, `subscribers`, `events`, `media`, `users`, `audit` — alles seed-bar und im Admin pflegbar.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hd-commerce-mcp",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hd-commerce-mcp",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hd-commerce-mcp",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "MCP-Server für hd-commerce — bearbeitet Produkte, Seiten, Slides, Popups & Einstellungen über die Admin-API.",
|
||||
|
||||
+7
-1
@@ -33,12 +33,15 @@ const TOOLS = [
|
||||
{ name: 'list_orders', description: 'Bestellungen auflisten (nur lesen).', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'list_slides', description: 'Slider-Slides auflisten.', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'upsert_slide', description: 'Slide anlegen/aktualisieren.', inputSchema: { type: 'object', properties: { slide: { type: 'object' } }, required: ['slide'] } },
|
||||
{ name: 'list_discounts', description: 'Alle Rabatte/Gutscheine auflisten.', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'upsert_discount', description: 'Rabatt anlegen/aktualisieren. Mit id oder code => Update, sonst Create. type: percent|fixed|freeshipping; value bei percent 1-100, bei fixed in Cent.', inputSchema: { type: 'object', properties: { discount: { type: 'object', description: 'Felder: code, title, type, value, min_order_cents, starts_at, expires_at, max_uses, max_per_customer, active, secret, auto' } }, required: ['discount'] } },
|
||||
{ name: 'delete_discount', description: 'Rabatt löschen (per ID).', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
|
||||
{ name: 'get_settings', description: 'Shop-Einstellungen (Key/Value) holen.', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'update_settings', description: 'Shop-Einstellungen aktualisieren (Key/Value-Map, z.B. shop_name, brand_accent).', inputSchema: { type: 'object', properties: { settings: { type: 'object' } }, required: ['settings'] } },
|
||||
{ name: 'get_manifest', description: 'API-Manifest (alle Ressourcen, Felder, Block-Typen).', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
const server = new Server({ name: 'hd-commerce', version: '2.0.0' }, { capabilities: { tools: {} } });
|
||||
const server = new Server({ name: 'hd-commerce', version: '2.1.0' }, { capabilities: { tools: {} } });
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
||||
@@ -57,6 +60,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
||||
case 'list_orders': out = await api('GET', '/api/admin/orders'); break;
|
||||
case 'list_slides': out = await api('GET', '/api/admin/slides'); break;
|
||||
case 'upsert_slide': out = await api('POST', '/api/admin/slides', a.slide); break;
|
||||
case 'list_discounts': out = await api('GET', '/api/admin/discounts'); break;
|
||||
case 'upsert_discount': out = await api('POST', '/api/admin/discounts', a.discount); break;
|
||||
case 'delete_discount': out = await api('DELETE', '/api/admin/discounts/' + encodeURIComponent(a.id)); break;
|
||||
case 'get_settings': out = await api('GET', '/api/admin/settings'); break;
|
||||
case 'update_settings': out = await api('POST', '/api/admin/settings', a.settings); break;
|
||||
case 'get_manifest': out = await api('GET', '/api/admin'); break;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hd-commerce",
|
||||
"version": "1.0.0",
|
||||
"version": "2.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hd-commerce",
|
||||
"version": "1.0.0",
|
||||
"version": "2.1.0",
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.1.3",
|
||||
"@fontsource-variable/fraunces": "^5.1.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "hd-commerce",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"description": "hd-commerce — neutrales SQLite-Commerce-Backend (Admin + API + Demo-Storefront)",
|
||||
"scripts": {
|
||||
|
||||
+48
-15
@@ -1,4 +1,5 @@
|
||||
/* hd-commerce — Popup-Engine (vanilla). Frequenz-Cap via localStorage. */
|
||||
/* hd-commerce — Popup-Engine (vanilla). Frequenz-Cap via localStorage.
|
||||
Stile: modal (zentral), slidein (unten rechts), bar (oben). Typ discount: Code + Kopieren. */
|
||||
(function () {
|
||||
var root = document.getElementById('popupRoot');
|
||||
if (!root) return;
|
||||
@@ -19,25 +20,56 @@
|
||||
localStorage.setItem('hdc_popup_' + p.id, String(Date.now()));
|
||||
if (p.freq === 'session') sessionStorage.setItem('hdc_ps_' + p.id, '1');
|
||||
}
|
||||
function build(p) {
|
||||
var ov = document.createElement('div');
|
||||
ov.className = 'hdc-popup-overlay';
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]; }); }
|
||||
|
||||
function innerHtml(p) {
|
||||
var isNl = p.type === 'newsletter';
|
||||
ov.innerHTML =
|
||||
'<div class="hdc-popup">' +
|
||||
'<button class="px" aria-label="Schließen">×</button>' +
|
||||
(p.image ? '<img src="' + p.image + '" alt="" style="border-radius:10px;margin-bottom:16px;max-height:160px;width:100%;object-fit:cover">' : '') +
|
||||
'<h3>' + (p.headline || '') + '</h3>' +
|
||||
'<p>' + (p.body || '') + '</p>' +
|
||||
(isNl
|
||||
? '<form class="nl-form"><input type="email" required placeholder="deine@email.de"><button class="btn btn-primary btn-block" type="submit">' + (p.cta_text || 'Anmelden') + '</button><div class="nl-msg"></div></form>'
|
||||
: (p.cta_url ? '<a class="btn btn-primary btn-lg" href="' + p.cta_url + '">' + (p.cta_text || 'Mehr') + '</a>' : '')) +
|
||||
'</div>';
|
||||
var isDisc = p.type === 'discount' && p.discount_code;
|
||||
var html =
|
||||
(p.image ? '<img src="' + esc(p.image) + '" alt="" style="border-radius:10px;margin-bottom:14px;max-height:160px;width:100%;object-fit:cover">' : '') +
|
||||
'<h3>' + esc(p.headline) + '</h3>' +
|
||||
'<p>' + esc(p.body) + '</p>';
|
||||
if (isDisc) {
|
||||
html += '<div class="hdc-code"><code>' + esc(p.discount_code) + '</code>' +
|
||||
'<button type="button" class="hdc-copy" data-code="' + esc(p.discount_code) + '">Kopieren</button></div>';
|
||||
if (p.cta_url) html += '<a class="btn btn-primary btn-block" href="' + esc(p.cta_url) + '">' + esc(p.cta_text || 'Zum Shop') + '</a>';
|
||||
} else if (isNl) {
|
||||
html += '<form class="nl-form"><input type="email" required placeholder="deine@email.de"><button class="btn btn-primary btn-block" type="submit">' + esc(p.cta_text || 'Anmelden') + '</button><div class="nl-msg"></div></form>';
|
||||
} else if (p.cta_url) {
|
||||
html += '<a class="btn btn-primary btn-lg" href="' + esc(p.cta_url) + '">' + esc(p.cta_text || 'Mehr') + '</a>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function build(p) {
|
||||
var style = p.style || 'modal';
|
||||
var ov, card;
|
||||
if (style === 'modal') {
|
||||
ov = document.createElement('div');
|
||||
ov.className = 'hdc-popup-overlay';
|
||||
ov.innerHTML = '<div class="hdc-popup"><button class="px" aria-label="Schließen">×</button>' + innerHtml(p) + '</div>';
|
||||
card = ov.querySelector('.hdc-popup');
|
||||
} else {
|
||||
ov = document.createElement('div');
|
||||
ov.className = 'hdc-popup-' + (style === 'bar' ? 'bar' : 'slidein');
|
||||
ov.innerHTML = '<div class="hdc-pp-inner"><button class="px" aria-label="Schließen">×</button>' + innerHtml(p) + '</div>';
|
||||
card = ov;
|
||||
}
|
||||
document.body.appendChild(ov);
|
||||
requestAnimationFrame(function () { ov.classList.add('show'); });
|
||||
|
||||
function close() { ov.classList.remove('show'); mark(p); setTimeout(function () { ov.remove(); }, 320); }
|
||||
ov.querySelector('.px').addEventListener('click', close);
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
if (style === 'modal') ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
|
||||
var copyBtn = ov.querySelector('.hdc-copy');
|
||||
if (copyBtn) copyBtn.addEventListener('click', function () {
|
||||
var code = copyBtn.getAttribute('data-code');
|
||||
try { navigator.clipboard.writeText(code); } catch (e) {}
|
||||
var old = copyBtn.textContent; copyBtn.textContent = 'Kopiert!';
|
||||
setTimeout(function () { copyBtn.textContent = old; }, 1600);
|
||||
});
|
||||
|
||||
var form = ov.querySelector('form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (e) {
|
||||
@@ -52,6 +84,7 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function arm(p) {
|
||||
if (seen(p)) return;
|
||||
var trig = p.trigger || 'delay';
|
||||
|
||||
@@ -28,6 +28,7 @@ const allNav = [
|
||||
{ key:'kunden', label:'Kunden', href: base + '/kunden', icon:'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0 2c-4 0-8 2-8 5v1h16v-1c0-3-4-5-8-5Z' },
|
||||
{ key:'analytics', label:'Analytics', href: base + '/analytics', icon:'M4 20V10m6 10V4m6 16v-7m4 7H2' },
|
||||
{ key:'marketing', label:'Marketing', href: base + '/marketing', icon:'M3 11l18-5v12l-7-2v4l-4-1v-3L3 11Z' },
|
||||
{ key:'rabatte', label:'Rabatte', href: base + '/rabatte', icon:'M9 9h.01M15 15h.01M8 21l13-13a2.83 2.83 0 0 0 0-4 2.83 2.83 0 0 0-4 0L4 17a2 2 0 0 0 0 3 2 2 0 0 0 4 1Z' },
|
||||
{ key:'inhalte', label:'Inhalte', href: base + '/inhalte', icon:'M4 4h16v4H4V4Zm0 6h10v10H4V10Zm12 0h4v10h-4V10Z' },
|
||||
];
|
||||
const ownerNav = [
|
||||
|
||||
+13
-2
@@ -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 1–100, bei fixed in Cent, bei freeshipping ignoriert. Codes werden case-insensitiv geprüft.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
@@ -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));
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ function sectionOf(adminInner) {
|
||||
const seg = adminInner.replace(/^\//, '').split('/')[0] || 'dashboard';
|
||||
const map = {
|
||||
'': 'dashboard', 'bestellungen': 'bestellungen', 'produkte': 'produkte', 'kunden': 'kunden',
|
||||
'analytics': 'analytics', 'marketing': 'marketing', 'inhalte': 'inhalte', 'einstellungen': 'einstellungen',
|
||||
'analytics': 'analytics', 'marketing': 'marketing', 'rabatte': 'rabatte', 'inhalte': 'inhalte', 'einstellungen': 'einstellungen',
|
||||
'nutzer': 'nutzer', 'audit': 'audit', 'konto': 'dashboard', 'login': 'login', 'logout': 'logout',
|
||||
};
|
||||
return map[seg] || 'dashboard';
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import BlockRenderer from '../components/BlockRenderer.astro';
|
||||
import { getPageBySlug } from '../lib/store.js';
|
||||
|
||||
// Gebrandete, im Block-Builder editierbare 404. Astro liefert diese Datei
|
||||
// automatisch bei unbekannten Routen aus.
|
||||
const page = getPageBySlug('404');
|
||||
const blocks = (page && Array.isArray(page.blocks) && page.blocks.length) ? page.blocks : null;
|
||||
const title = (page && page.title) || 'Seite nicht gefunden';
|
||||
---
|
||||
<Base title={title}>
|
||||
{blocks ? (
|
||||
<BlockRenderer blocks={blocks} />
|
||||
) : (page && page.body) ? (
|
||||
<section class="blk blk-rich"><div class="wrap prose" set:html={page.body}></div></section>
|
||||
) : (
|
||||
<section class="blk blk-hero align-center">
|
||||
<div class="wrap blk-hero-inner">
|
||||
<h1>Seite nicht gefunden</h1>
|
||||
<p class="blk-hero-sub">Diese Seite gibt es nicht oder sie wurde verschoben.</p>
|
||||
<a class="btn btn-primary btn-lg" href="/">Zur Startseite</a>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</Base>
|
||||
@@ -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>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createOrder, getSetting } from '../../lib/store.js';
|
||||
import { createOrder, getSetting, validateDiscount, redeemDiscount, bestAutoDiscount } from '../../lib/store.js';
|
||||
export const prerender = false;
|
||||
const keyLooksReal = (k) => typeof k === 'string' && /^sk_(test|live)_[A-Za-z0-9]{16,}/.test(k.trim());
|
||||
function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); }
|
||||
@@ -16,16 +16,40 @@ export async function POST({ request }) {
|
||||
}));
|
||||
const subtotal = lineItems.reduce((s, i) => s + i.priceCents * i.qty, 0);
|
||||
const freeShip = Number(getSetting('free_shipping_cents', '4900')) || 4900;
|
||||
const shipping = subtotal >= freeShip ? 0 : 490;
|
||||
const total = subtotal + shipping;
|
||||
let shipping = subtotal >= freeShip ? 0 : 490;
|
||||
const customer_name = [contact.vorname, contact.nachname].filter(Boolean).join(' ').trim();
|
||||
const email = contact.email || '';
|
||||
|
||||
// Rabattcode serverseitig erneut validieren (niemals dem Client vertrauen).
|
||||
let discount = null; // { id, code, amountCents, freeShipping }
|
||||
const rawCode = String(body.code || '').trim();
|
||||
if (rawCode) {
|
||||
const v = validateDiscount(rawCode, subtotal, email || undefined);
|
||||
if (v.ok) discount = v;
|
||||
}
|
||||
// Kein (gültiger) Code? Bestes automatisches Discount anwenden, falls Bedingungen erfüllt.
|
||||
if (!discount) {
|
||||
const auto = bestAutoDiscount(subtotal);
|
||||
if (auto && auto.ok) discount = auto;
|
||||
}
|
||||
let discountCents = 0;
|
||||
if (discount) {
|
||||
if (discount.freeShipping) { shipping = 0; }
|
||||
else { discountCents = Math.min(discount.amountCents, subtotal); }
|
||||
}
|
||||
const total = Math.max(0, subtotal - discountCents + shipping);
|
||||
|
||||
const order = await createOrder({
|
||||
email, customer_name, items: lineItems, total_cents: total, status: 'pending',
|
||||
address: [contact.strasse, contact.plz, contact.ort, contact.land].filter(Boolean).join(', '),
|
||||
discount_code: discount ? discount.code : '', discount_cents: discountCents,
|
||||
});
|
||||
|
||||
// Einlösung verbuchen (used_count + Redemption).
|
||||
if (discount) {
|
||||
redeemDiscount(discount.id, discount.code, email, order.id, discount.freeShipping ? 0 : discountCents);
|
||||
}
|
||||
|
||||
const secret = process.env.STRIPE_SECRET_KEY || '';
|
||||
const origin = new URL(request.url).origin;
|
||||
|
||||
@@ -33,7 +57,7 @@ export async function POST({ request }) {
|
||||
try {
|
||||
const Stripe = (await import('stripe')).default;
|
||||
const stripe = new Stripe(secret);
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
const sessionCfg = {
|
||||
mode: 'payment', payment_method_types: ['card'], locale: 'de',
|
||||
customer_email: email || undefined,
|
||||
line_items: [
|
||||
@@ -43,7 +67,15 @@ export async function POST({ request }) {
|
||||
],
|
||||
success_url: `${origin}/bestellung-erfolgreich?order=${order.number}`,
|
||||
cancel_url: `${origin}/warenkorb`, metadata: { order_number: order.number },
|
||||
});
|
||||
};
|
||||
// Warenkorb-Rabatt als Stripe-Coupon (amount_off) anhängen.
|
||||
if (discountCents > 0) {
|
||||
try {
|
||||
const coupon = await stripe.coupons.create({ amount_off: discountCents, currency: 'eur', duration: 'once', name: discount.code });
|
||||
sessionCfg.discounts = [{ coupon: coupon.id }];
|
||||
} catch {}
|
||||
}
|
||||
const session = await stripe.checkout.sessions.create(sessionCfg);
|
||||
return json({ url: session.url });
|
||||
} catch (e) { return json({ url: `/bestellung-erfolgreich?order=${order.number}&demo=1` }); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { validateDiscount, formatPrice } from '../../lib/store.js';
|
||||
export const prerender = false;
|
||||
function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); }
|
||||
|
||||
// POST /api/discount { code, items:[{priceCents|price, qty}], email? }
|
||||
// -> berechnet subtotal serverseitig und validiert den Code.
|
||||
export async function POST({ request }) {
|
||||
let body;
|
||||
try { body = await request.json(); } catch { return json({ ok: false, reason: 'Bad request' }, 400); }
|
||||
const code = String(body.code || '').trim();
|
||||
if (!code) return json({ ok: false, reason: 'Kein Code angegeben' }, 400);
|
||||
const items = Array.isArray(body.items) ? body.items : [];
|
||||
const subtotal = items.reduce((s, i) => {
|
||||
const cents = Math.round(Number(i.priceCents) || Number(i.price) * 100 || 0);
|
||||
const qty = Math.max(1, parseInt(i.qty) || 1);
|
||||
return s + cents * qty;
|
||||
}, 0);
|
||||
const email = body.email ? String(body.email) : undefined;
|
||||
const res = validateDiscount(code, subtotal, email);
|
||||
if (!res.ok) return json({ ok: false, reason: res.reason });
|
||||
const label = res.freeShipping
|
||||
? 'Gratisversand'
|
||||
: (res.type === 'percent' ? `${res.value}% Rabatt` : `Rabatt ${formatPrice(res.amountCents)}`);
|
||||
return json({ ok: true, code: res.code, amountCents: res.amountCents, freeShipping: !!res.freeShipping, type: res.type, value: res.value, label, title: res.title || '' });
|
||||
}
|
||||
@@ -24,23 +24,62 @@ const hasStripe = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test((process.env.STRIPE_SE
|
||||
<button class="btn btn-primary btn-lg btn-block" type="submit" id="coBtn" style="margin-top:8px">Kostenpflichtig bestellen</button>
|
||||
<div id="coMsg" style="margin-top:12px;color:var(--accent);font-size:14px"></div>
|
||||
</form>
|
||||
<div class="summary"><div id="coSummary"></div></div>
|
||||
<div class="summary">
|
||||
<div id="coSummary"></div>
|
||||
<div class="disc-box" style="margin-top:14px;padding-top:14px;border-top:1px solid var(--line,#e7e2da)">
|
||||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:6px">Gutscheincode</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input id="discCode" placeholder="z. B. WILLKOMMEN10" style="flex:1;padding:10px 12px;border:1px solid var(--line,#d9d3ca);border-radius:8px;text-transform:uppercase" />
|
||||
<button type="button" class="btn btn-ghost" id="discApply">Einlösen</button>
|
||||
</div>
|
||||
<div id="discMsg" style="margin-top:8px;font-size:13px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline define:vars={{ freeShip, currency }}>
|
||||
(function () {
|
||||
var discount = null; // { code, amountCents, freeShipping, label }
|
||||
function fmt(c) { try { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency }).format((c||0)/100); } catch(e){ return ((c||0)/100).toFixed(2)+' '+currency; } }
|
||||
function summary() {
|
||||
var items = window.HDC.read(), sub = window.HDC.subtotal(), ship = sub >= freeShip ? 0 : 490;
|
||||
var items = window.HDC.read(), sub = window.HDC.subtotal();
|
||||
var box = document.getElementById('coSummary');
|
||||
if (!items.length) { box.innerHTML = '<p>Dein Warenkorb ist leer. <a class="s-link" href="/shop">Zum Shop</a></p>'; return; }
|
||||
box.innerHTML = items.map(function (i) { return '<div class="sum-row"><span>' + i.qty + '× ' + i.name + (i.size && i.size!=='One Size' ? ' ('+i.size+')':'') + '</span><span>' + fmt(i.priceCents*i.qty) + '</span></div>'; }).join('') +
|
||||
'<div class="sum-row"><span>Versand</span><span>' + (ship===0?'Kostenlos':fmt(ship)) + '</span></div>' +
|
||||
'<div class="sum-row total"><span>Gesamt</span><span>' + fmt(sub+ship) + '</span></div>';
|
||||
var freeByDisc = discount && discount.freeShipping;
|
||||
var ship = (freeByDisc || sub >= freeShip) ? 0 : 490;
|
||||
var discCents = (discount && !discount.freeShipping) ? Math.min(discount.amountCents, sub) : 0;
|
||||
var total = Math.max(0, sub - discCents + ship);
|
||||
var html = items.map(function (i) { return '<div class="sum-row"><span>' + i.qty + '× ' + i.name + (i.size && i.size!=='One Size' ? ' ('+i.size+')':'') + '</span><span>' + fmt(i.priceCents*i.qty) + '</span></div>'; }).join('');
|
||||
html += '<div class="sum-row"><span>Zwischensumme</span><span>' + fmt(sub) + '</span></div>';
|
||||
if (discount) {
|
||||
if (discount.freeShipping) html += '<div class="sum-row" style="color:var(--accent)"><span>Gutschein ' + discount.code + '</span><span>Gratisversand</span></div>';
|
||||
else html += '<div class="sum-row" style="color:var(--accent)"><span>Gutschein ' + discount.code + '</span><span>−' + fmt(discCents) + '</span></div>';
|
||||
}
|
||||
html += '<div class="sum-row"><span>Versand</span><span>' + (ship===0?'Kostenlos':fmt(ship)) + '</span></div>';
|
||||
html += '<div class="sum-row total"><span>Gesamt</span><span>' + fmt(total) + '</span></div>';
|
||||
box.innerHTML = html;
|
||||
}
|
||||
function applyCode() {
|
||||
var input = document.getElementById('discCode'), msg = document.getElementById('discMsg');
|
||||
var code = (input.value || '').trim();
|
||||
if (!code) { discount = null; msg.textContent=''; summary(); return; }
|
||||
var items = window.HDC.read();
|
||||
var email = (document.querySelector('[name=email]') || {}).value || '';
|
||||
msg.style.color = 'var(--faint)'; msg.textContent = 'Prüfe …';
|
||||
fetch('/api/discount', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ code: code, items: items, email: email }) })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
if (d.ok) { discount = d; msg.style.color='var(--accent)'; msg.textContent = 'Code „' + d.code + '" angewendet — ' + d.label + '.'; }
|
||||
else { discount = null; msg.style.color='#b3261e'; msg.textContent = d.reason || 'Code ungültig'; }
|
||||
summary();
|
||||
})
|
||||
.catch(function(){ msg.style.color='#b3261e'; msg.textContent='Bitte später erneut versuchen.'; });
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
summary();
|
||||
document.getElementById('discApply').addEventListener('click', applyCode);
|
||||
document.getElementById('discCode').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); applyCode(); } });
|
||||
var f = document.getElementById('coForm');
|
||||
f.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
@@ -49,7 +88,7 @@ const hasStripe = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test((process.env.STRIPE_SE
|
||||
window.HDC.track('checkout_start', window.HDC.subtotal());
|
||||
var btn = document.getElementById('coBtn'); btn.disabled = true; btn.textContent = 'Wird verarbeitet …';
|
||||
var fd = new FormData(f), contact = {}; fd.forEach(function (v, k) { contact[k] = v; });
|
||||
fetch('/api/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: items, contact: contact }) })
|
||||
fetch('/api/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: items, contact: contact, code: discount ? discount.code : '' }) })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) { if (d.url) { window.HDC.clear(); window.location.href = d.url; } else { throw new Error(d.error || 'Fehler'); } })
|
||||
.catch(function (err) { document.getElementById('coMsg').textContent = err.message; btn.disabled = false; btn.textContent = 'Kostenpflichtig bestellen'; });
|
||||
|
||||
@@ -187,6 +187,35 @@ p{margin:0 0 1rem}
|
||||
.hdc-popup .nl-form{flex-direction:column}
|
||||
.hdc-popup .nl-form input{border:1.5px solid var(--border-2);width:100%}
|
||||
|
||||
/* Rabatt-Code-Box in Popups */
|
||||
.hdc-code{display:flex;gap:8px;align-items:stretch;margin:6px 0 16px}
|
||||
.hdc-code code{flex:1;display:flex;align-items:center;justify-content:center;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:18px;font-weight:700;letter-spacing:.08em;padding:12px 14px;border:1.5px dashed var(--accent);border-radius:10px;color:var(--ink);background:color-mix(in srgb,var(--accent) 8%,white)}
|
||||
.hdc-copy{border:none;background:var(--accent);color:#fff;font-weight:600;padding:0 16px;border-radius:10px;cursor:pointer;font-size:14px}
|
||||
.hdc-copy:hover{background:var(--accent-dark)}
|
||||
|
||||
/* Slide-in (unten rechts) */
|
||||
.hdc-popup-slidein{position:fixed;right:20px;bottom:20px;z-index:120;max-width:360px;width:calc(100% - 40px);opacity:0;transform:translateY(24px);pointer-events:none;transition:.32s}
|
||||
.hdc-popup-slidein.show{opacity:1;transform:none;pointer-events:auto}
|
||||
.hdc-popup-slidein .hdc-pp-inner{position:relative;background:var(--surface);border-radius:var(--radius);padding:24px 22px;box-shadow:var(--shadow-lg);text-align:left}
|
||||
.hdc-popup-slidein h3{font-size:20px;margin-bottom:8px}
|
||||
.hdc-popup-slidein p{color:var(--subtle);margin-bottom:16px;font-size:14px}
|
||||
.hdc-popup-slidein .px,.hdc-popup-bar .px{position:absolute;top:10px;right:12px;background:none;border:none;font-size:22px;color:var(--faint);cursor:pointer;line-height:1}
|
||||
.hdc-popup-slidein .nl-form{flex-direction:column}
|
||||
.hdc-popup-slidein .nl-form input{border:1.5px solid var(--border-2);width:100%}
|
||||
|
||||
/* Bar (oben) */
|
||||
.hdc-popup-bar{position:fixed;left:0;right:0;top:0;z-index:130;background:var(--accent);color:#fff;transform:translateY(-100%);transition:.32s;opacity:0}
|
||||
.hdc-popup-bar.show{transform:none;opacity:1}
|
||||
.hdc-popup-bar .hdc-pp-inner{position:relative;max-width:1100px;margin:0 auto;padding:12px 48px 12px 20px;display:flex;align-items:center;gap:14px;flex-wrap:wrap;justify-content:center}
|
||||
.hdc-popup-bar h3{font-size:16px;margin:0;color:#fff}
|
||||
.hdc-popup-bar p{margin:0;font-size:14px;color:rgba(255,255,255,.92)}
|
||||
.hdc-popup-bar .px{color:#fff;top:50%;transform:translateY(-50%)}
|
||||
.hdc-popup-bar img{display:none}
|
||||
.hdc-popup-bar .hdc-code{margin:0}
|
||||
.hdc-popup-bar .hdc-code code{background:rgba(255,255,255,.16);border-color:rgba(255,255,255,.6);color:#fff;padding:6px 12px;font-size:15px}
|
||||
.hdc-popup-bar .hdc-copy{background:#fff;color:var(--accent)}
|
||||
.hdc-popup-bar .btn{padding:7px 16px}
|
||||
|
||||
@media(max-width:880px){
|
||||
.pdp{grid-template-columns:1fr;gap:28px}
|
||||
.cart-wrap{grid-template-columns:1fr}
|
||||
|
||||
Reference in New Issue
Block a user