diff --git a/README.md b/README.md index 1ea5b96..7342c82 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/mcp/package-lock.json b/mcp/package-lock.json index e321f46..ad21512 100644 --- a/mcp/package-lock.json +++ b/mcp/package-lock.json @@ -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" }, diff --git a/mcp/package.json b/mcp/package.json index f64be14..7c9d16c 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -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.", diff --git a/mcp/server.js b/mcp/server.js index d3dac53..b9db2cc 100644 --- a/mcp/server.js +++ b/mcp/server.js @@ -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; diff --git a/package-lock.json b/package-lock.json index 80e0fa4..5528b4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a6de80c..ac17de9 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/public/popups.js b/public/popups.js index 0cc4fd8..075e0a2 100644 --- a/public/popups.js +++ b/public/popups.js @@ -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 = - '
' + - '' + - (p.image ? '' : '') + - '

' + (p.headline || '') + '

' + - '

' + (p.body || '') + '

' + - (isNl - ? '
' - : (p.cta_url ? '' + (p.cta_text || 'Mehr') + '' : '')) + - '
'; + var isDisc = p.type === 'discount' && p.discount_code; + var html = + (p.image ? '' : '') + + '

' + esc(p.headline) + '

' + + '

' + esc(p.body) + '

'; + if (isDisc) { + html += '
' + esc(p.discount_code) + '' + + '
'; + if (p.cta_url) html += '' + esc(p.cta_text || 'Zum Shop') + ''; + } else if (isNl) { + html += '
'; + } else if (p.cta_url) { + html += '' + esc(p.cta_text || 'Mehr') + ''; + } + 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 = '
' + innerHtml(p) + '
'; + card = ov.querySelector('.hdc-popup'); + } else { + ov = document.createElement('div'); + ov.className = 'hdc-popup-' + (style === 'bar' ? 'bar' : 'slidein'); + ov.innerHTML = '
' + innerHtml(p) + '
'; + 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'; diff --git a/src/layouts/Admin.astro b/src/layouts/Admin.astro index 5cea3e4..d1dae09 100644 --- a/src/layouts/Admin.astro +++ b/src/layouts/Admin.astro @@ -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 = [ diff --git a/src/lib/admin-api.js b/src/lib/admin-api.js index e63df7a..baa5c3e 100644 --- a/src/lib/admin-api.js +++ b/src/lib/admin-api.js @@ -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 ', 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, : ... } — 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.', ], }; } diff --git a/src/lib/auth.js b/src/lib/auth.js index 535dc3c..c510fb1 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -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) { diff --git a/src/lib/store-sqlite.js b/src/lib/store-sqlite.js index 0b26262..0f271dc 100644 --- a/src/lib/store-sqlite.js +++ b/src/lib/store-sqlite.js @@ -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)); diff --git a/src/middleware.js b/src/middleware.js index 420994e..8f22150 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -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'; diff --git a/src/pages/404.astro b/src/pages/404.astro new file mode 100644 index 0000000..b38a8f6 --- /dev/null +++ b/src/pages/404.astro @@ -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'; +--- + + {blocks ? ( + + ) : (page && page.body) ? ( +
+ ) : ( +
+
+

Seite nicht gefunden

+

Diese Seite gibt es nicht oder sie wurde verschoben.

+ Zur Startseite +
+
+ )} + diff --git a/src/pages/admin/bestellungen/[id].astro b/src/pages/admin/bestellungen/[id].astro index 1723018..9a0006d 100644 --- a/src/pages/admin/bestellungen/[id].astro +++ b/src/pages/admin/bestellungen/[id].astro @@ -33,6 +33,12 @@ const statuses = [['pending', 'Offen'], ['fulfilled', 'Erfüllt'], ['cancelled', + {order.discount_cents > 0 && ( +
Rabatt{order.discount_code ? ` (${order.discount_code})` : ''}−{formatPrice(order.discount_cents)}
+ )} + {(order.discount_cents === 0 && order.discount_code) && ( +
Gutschein ({order.discount_code})Gratisversand
+ )}
Gesamt{formatPrice(order.total_cents)}
diff --git a/src/pages/admin/inhalte/index.astro b/src/pages/admin/inhalte/index.astro index a23cc9f..c7ebfdf 100644 --- a/src/pages/admin/inhalte/index.astro +++ b/src/pages/admin/inhalte/index.astro @@ -59,8 +59,8 @@ const tabs = [['pages', 'Seiten & Rechtstexte'], ['slider', 'Slider'], ['media', {pages.map((p) => ( {p.title} - /seite/{p.slug} - {p.type === 'legal' ? 'Rechtstext' : 'Inhalt'} + {p.type === 'system' ? `/${p.slug}` : `/seite/${p.slug}`} + {p.type === 'legal' ? 'Rechtstext' : (p.type === 'system' ? 'System' : 'Inhalt')} {p.active ? Aktiv : Aus} Editor diff --git a/src/pages/admin/marketing/index.astro b/src/pages/admin/marketing/index.astro index d83ec6c..3f8eb71 100644 --- a/src/pages/admin/marketing/index.astro +++ b/src/pages/admin/marketing/index.astro @@ -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 && }
+
+
+
+
diff --git a/src/pages/admin/rabatte/index.astro b/src/pages/admin/rabatte/index.astro new file mode 100644 index 0000000..aaa2aa3 --- /dev/null +++ b/src/pages/admin/rabatte/index.astro @@ -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']]; +--- + +
+ {flash &&
✓ {flash}
} + +
+
+
Gutscheine & Rabatte+ Neu
+
+ + + + {discounts.length === 0 ? () : + discounts.map((d) => { + const st = statusOf(d); + return ( + + + + + + + + + ); + })} + +
CodeTypWertVerbrauchStatus
Keine Rabatte
{d.code}{d.secret && geheim}{d.auto && auto}
{d.title}
{typeLabel[d.type] || d.type}{valueLabel(d)}{d.used_count}{d.max_uses != null ? ' / ' + d.max_uses : ''}{st[1]} + Bearbeiten +
+
+
+
+ +
+
{editing ? 'Rabatt bearbeiten' : 'Rabatt anlegen'}
+
+ + {editing && } +
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {editing && Abbrechen} +
+
+
+
+ + +
diff --git a/src/pages/api/checkout.js b/src/pages/api/checkout.js index 886696c..e44d0a6 100644 --- a/src/pages/api/checkout.js +++ b/src/pages/api/checkout.js @@ -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` }); } } diff --git a/src/pages/api/discount.js b/src/pages/api/discount.js new file mode 100644 index 0000000..ca26d14 --- /dev/null +++ b/src/pages/api/discount.js @@ -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 || '' }); +} diff --git a/src/pages/checkout.astro b/src/pages/checkout.astro index 5a3fa44..d1bd82c 100644 --- a/src/pages/checkout.astro +++ b/src/pages/checkout.astro @@ -24,23 +24,62 @@ const hasStripe = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test((process.env.STRIPE_SE
-
+
+
+
+ +
+ + +
+
+
+