diff --git a/.env.example b/.env.example index 3b863bd..3d6fa10 100644 --- a/.env.example +++ b/.env.example @@ -49,3 +49,10 @@ SMTP_PORT=587 SMTP_USER= SMTP_PASS= SMTP_SECURE=false + +# --- Feature-Module (v2.3) --- +# Warenkorb-Erinnerung: Token, das den Cron-Endpoint /api/cron/abandoned schützt. +# Leer => Endpoint bleibt gesperrt (401). Coolify-Scheduled-Task ruft den Endpoint mit diesem Token. +CRON_TOKEN= +# Karten, die älter als X Minuten sind und weder bezahlt noch erinnert wurden, werden erinnert. +ABANDONED_AFTER_MINUTES=30 diff --git a/README.md b/README.md index 543d3d7..6fedc28 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,12 @@ Die mitgelieferte Demo-Instanz heißt **„Brittas Nähkiste"** (Kurzwaren/Nähb - **Versandzonen** (Tabelle `shipping_zones`, Admin „Versand"): länderbasierte Preise mit Gratis-ab-Schwelle; Helper `shippingFor(country, subtotal)`; Checkout berechnet Versand serverseitig neu. - **Bestell-/Versandmails** (`src/lib/mailer.js`): Provider **Listmonk** (Transactional-API) / **SMTP** (nodemailer) / **Log-Fallback** (Tabelle `email_log`, Admin „E-Mail-Log"). Gebrandete Bestellbestätigung bei bezahlter Bestellung. - **Feature-Flags**: Module pro Shop abschaltbar (`feature_newsletter`, `feature_accounts`, `feature_reviews`, `feature_wishlist`, `feature_abandoned_cart`, `feature_search`) über Admin → Einstellungen → Module; Helper `feature(key)`. +- **Feature-Module (v2.3):** über die jeweiligen Flags ge-gatet — ist ein Flag aus, verschwinden Storefront-Elemente, Routen liefern 302/404 und der Admin-Nav-Punkt entfällt. + - **Volltextsuche** (`feature_search`): Suchfeld im Storefront-Header → Ergebnisseite `/suche?q=` (SSR, SQLite-`LIKE`, case-insensitiv über Name/Kurzname/Beschreibung/Material/Kategorie), Treffer als Produktkarten, „keine Treffer"-Zustand. + - **Merkliste** (`feature_wishlist`): Herz-Button auf Produktkarten & Detailseite, Speicherung clientseitig (localStorage, `public/wishlist.js`), Seite `/merkliste`. + - **Kundenkonten + Adressbuch** (`feature_accounts`): eigene Kunden-Session (Cookie `hdc_customer`, getrennt vom Admin; scrypt-Hash). `/konto/registrieren`, `/konto/anmelden`, `/konto` (Bestellhistorie + Adressbuch), `/konto/abmelden`. Tabelle `customer_addresses`; Checkout füllt die Adresse vor und ordnet die Bestellung dem Konto zu (`orders.customer_id`). Gast-Checkout bleibt möglich. + - **Bewertungen** (`feature_reviews`): Tabelle `reviews` (Sterne 1–5, Moderation `approved`). Formular auf der Produktseite (`/api/review`, speichert `approved=0`), Anzeige von Durchschnitt + freigegebenen Reviews, optionale `aggregateRating` im Produkt-JSON-LD. Admin-Bereich **Bewertungen** (Owner/Redaktion): Freigeben/Verbergen/Löschen, Zähler offener Reviews in der Nav. + - **Warenkorb-Erinnerung** (`feature_abandoned_cart`): beim Checkout-Start wird der Warenkorb serverseitig in `abandoned_carts` gesichert (`/api/cart-capture`). Versand-Trigger: **`POST /api/cron/abandoned`** (Header `Authorization: Bearer ` oder `?token=`), schickt für fällige, nicht erinnerte Karten eine gebrandete Erinnerungsmail (Mailer/Log-Fallback) und setzt `reminded=1`. Erfolgreiche Bestellung der Adresse setzt `recovered=1`. Status/Zähler unter Einstellungen. Als **Coolify-Scheduled-Task** z. B. alle 30 Min `curl -fsS -X POST -H "Authorization: Bearer $CRON_TOKEN" https://shop.example.com/api/cron/abandoned` aufrufen. - **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). @@ -49,6 +55,8 @@ Die mitgelieferte Demo-Instanz heißt **„Brittas Nähkiste"** (Kurzwaren/Nähb | `MAIL_FROM` | Absenderadresse ausgehender Mails | – | | `LISTMONK_URL` / `LISTMONK_USER` / `LISTMONK_PASS` / `LISTMONK_TX_TEMPLATE_ID` | Listmonk Transactional-API | – | | `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` / `SMTP_SECURE` | SMTP-Versand (nodemailer) | – | +| `CRON_TOKEN` | Bearer-Token für `/api/cron/abandoned` (Warenkorb-Erinnerung). Leer ⇒ Endpoint gesperrt | – | +| `ABANDONED_AFTER_MINUTES` | Alter (Minuten), ab dem eine offene Warenkorb-Karte erinnert wird | `30` | Siehe `.env.example`. diff --git a/mcp/server.js b/mcp/server.js index 44c3f73..0ccb61a 100644 --- a/mcp/server.js +++ b/mcp/server.js @@ -41,10 +41,14 @@ const TOOLS = [ { name: 'delete_shipping', description: 'Versandzone 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: 'list_reviews', description: 'Produktbewertungen auflisten (inkl. approved-Status).', inputSchema: { type: 'object', properties: {} } }, + { name: 'moderate_review', description: 'Bewertung moderieren: { id, approved: true|false }. Ohne id: neue Bewertung { product_slug, name, rating(1-5), text }.', inputSchema: { type: 'object', properties: { review: { type: 'object' } }, required: ['review'] } }, + { name: 'delete_review', description: 'Bewertung löschen (per ID).', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } }, + { name: 'list_abandoned_carts', description: 'Abgebrochene Warenkörbe auflisten (nur lesen).', inputSchema: { type: 'object', properties: {} } }, { name: 'get_manifest', description: 'API-Manifest (alle Ressourcen, Felder, Block-Typen).', inputSchema: { type: 'object', properties: {} } }, ]; -const server = new Server({ name: 'hd-commerce', version: '2.2.0' }, { capabilities: { tools: {} } }); +const server = new Server({ name: 'hd-commerce', version: '2.3.0' }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); server.setRequestHandler(CallToolRequestSchema, async (req) => { @@ -71,6 +75,10 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { case 'delete_shipping': out = await api('DELETE', '/api/admin/shipping_zones/' + 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 'list_reviews': out = await api('GET', '/api/admin/reviews'); break; + case 'moderate_review': out = await api('POST', '/api/admin/reviews', a.review); break; + case 'delete_review': out = await api('DELETE', '/api/admin/reviews/' + encodeURIComponent(a.id)); break; + case 'list_abandoned_carts': out = await api('GET', '/api/admin/abandoned_carts'); break; case 'get_manifest': out = await api('GET', '/api/admin'); break; default: throw new Error('Unbekanntes Tool: ' + name); } diff --git a/package.json b/package.json index 5b29965..55d6692 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hd-commerce", "type": "module", - "version": "2.2.0", + "version": "2.3.0", "private": true, "description": "hd-commerce — neutrales SQLite-Commerce-Backend (Admin + API + Demo-Storefront)", "scripts": { diff --git a/public/wishlist.js b/public/wishlist.js new file mode 100644 index 0000000..b2c42d1 --- /dev/null +++ b/public/wishlist.js @@ -0,0 +1,75 @@ +/* hd-commerce — Merkliste (Wunschliste) clientseitig via localStorage. */ +(function () { + var KEY = 'hdc_wishlist'; + function read() { try { return JSON.parse(localStorage.getItem(KEY) || '[]'); } catch (e) { return []; } } + function write(c) { localStorage.setItem(KEY, JSON.stringify(c)); updateBadge(); paintButtons(); } + function has(slug) { return read().some(function (i) { return i.slug === slug; }); } + function toggle(item) { + var c = read(); + var idx = c.findIndex(function (i) { return i.slug === item.slug; }); + if (idx > -1) { c.splice(idx, 1); } else { c.push(item); } + write(c); + return idx === -1; // true wenn jetzt gemerkt + } + function remove(slug) { write(read().filter(function (i) { return i.slug !== slug; })); } + function updateBadge() { + var b = document.getElementById('wishBadge'); + if (!b) return; + var n = read().length; + b.textContent = n; + b.classList.toggle('show', n > 0); + } + function paintButtons() { + document.querySelectorAll('[data-wish]').forEach(function (btn) { + var p; try { p = JSON.parse(btn.getAttribute('data-wish') || '{}'); } catch (e) { p = {}; } + btn.classList.toggle('active', has(p.slug)); + btn.setAttribute('aria-pressed', has(p.slug) ? 'true' : 'false'); + }); + } + window.HDCWish = { read: read, has: has, toggle: toggle, remove: remove, count: function () { return read().length; } }; + document.addEventListener('DOMContentLoaded', function () { + updateBadge(); + paintButtons(); + document.querySelectorAll('[data-wish]').forEach(function (btn) { + btn.addEventListener('click', function (e) { + e.preventDefault(); e.stopPropagation(); + var p; try { p = JSON.parse(btn.getAttribute('data-wish') || '{}'); } catch (err) { p = {}; } + if (!p.slug) return; + var added = toggle(p); + if (window.HDC && window.HDC.track) { /* optional analytics */ } + if (typeof window.hdcWishToast === 'function') window.hdcWishToast(added ? 'Gemerkt' : 'Entfernt'); + }); + }); + // Merkliste-Seite rendern, falls vorhanden + var root = document.getElementById('wishlistRoot'); + if (root) renderList(root); + }); + + function fmt(c, cur) { try { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: cur || 'EUR' }).format((c||0)/100); } catch(e){ return ((c||0)/100).toFixed(2)+' '+(cur||'EUR'); } } + function renderList(root) { + var cur = root.getAttribute('data-currency') || 'EUR'; + function draw() { + var items = read(); + if (!items.length) { + root.innerHTML = '

Deine Merkliste ist leer

Tippe auf das Herz an einem Produkt, um es hier zu sammeln.

Zum Shop
'; + return; + } + var cards = items.map(function (p) { + return '
' + + '' + + '
' + (p.image ? '' : '') + '
' + + '
' + (p.category || '') + '' + + '' + (p.name || '') + '' + + '' + fmt(p.priceCents, cur) + '' + + 'inkl. MwSt.
' + + '
'; + }).join(''); + root.innerHTML = '
' + cards + '
'; + root.querySelectorAll('[data-rm]').forEach(function (b) { + b.addEventListener('click', function (e) { e.preventDefault(); remove(b.getAttribute('data-rm')); draw(); }); + }); + } + draw(); + } +})(); diff --git a/src/layouts/Admin.astro b/src/layouts/Admin.astro index 71a6e26..4094828 100644 --- a/src/layouts/Admin.astro +++ b/src/layouts/Admin.astro @@ -2,7 +2,7 @@ import '@fontsource-variable/public-sans'; import '@fontsource-variable/fraunces'; import '../styles/admin.css'; -import { getSettings } from '../lib/store.js'; +import { getSettings, feature, countPendingReviews } from '../lib/store.js'; import { currentUser, adminBase, allowedSections } from '../lib/auth.js'; export interface Props { title: string; active?: string; crumbs?: { label: string; href?: string }[]; } @@ -32,6 +32,9 @@ const allNav = [ { key:'inhalte', label:'Inhalte', href: base + '/inhalte', icon:'M4 4h16v4H4V4Zm0 6h10v10H4V10Zm12 0h4v10h-4V10Z' }, { key:'versandzonen', label:'Versand', href: base + '/versand', icon:'M3 7h11v8H3V7Zm11 3h4l3 3v2h-7v-5ZM7 19a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm10 0a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z' }, ]; +const reviewsOn = feature('feature_reviews'); +const pendingReviews = reviewsOn ? countPendingReviews() : 0; +if (reviewsOn) allNav.push({ key:'bewertungen', label:'Bewertungen', href: base + '/bewertungen', icon:'M12 17.3 6.2 21l1.6-6.6L3 9.8l6.7-.6L12 3l2.3 6.2 6.7.6-4.8 4.6L17.8 21 12 17.3Z', badge: pendingReviews }); const ownerNav = [ { key:'nutzer', label:'Nutzer & Zugänge', href: base + '/nutzer', icon:'M16 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0 2c-3 0-6 1.5-6 4v1h7M16 13c-3.3 0-6 1.7-6 4.2V19h12v-1.8c0-2.5-2.7-4.2-6-4.2Z' }, { key:'audit', label:'Aktivität (Audit)', href: base + '/audit', icon:'M12 8v4l3 2M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z' }, @@ -72,6 +75,7 @@ const paletteJson = JSON.stringify(paletteItems); {n.label} + {n.badge > 0 && {n.badge}} ))} {ownerItems.length > 0 &&
Verwaltung
} diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 7e647c9..bf9ec40 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -26,6 +26,9 @@ let popups = popupsForPath(path); // Feature-Flag: Newsletter-Popups nur zeigen, wenn Newsletter aktiv ist. const newsletterOn = feature('feature_newsletter'); if (!newsletterOn) popups = popups.filter((pp) => pp.type !== 'newsletter'); +const searchOn = feature('feature_search'); +const accountsOn = feature('feature_accounts'); +const wishlistOn = feature('feature_wishlist'); --- @@ -51,9 +54,23 @@ if (!newsletterOn) popups = popups.filter((pp) => pp.type !== 'newsletter'); Über uns
- - - + {searchOn && ( + + )} + {wishlistOn && ( + + + 0 + + )} + {accountsOn && ( + + + + )} 0 @@ -100,5 +117,6 @@ if (!newsletterOn) popups = popups.filter((pp) => pp.type !== 'newsletter'); + {wishlistOn && } diff --git a/src/lib/admin-api.js b/src/lib/admin-api.js index 253c0e9..1545550 100644 --- a/src/lib/admin-api.js +++ b/src/lib/admin-api.js @@ -28,6 +28,8 @@ export const RESOURCES = { 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', 'tax_cents', 'shipping_cents', 'country', 'items[]', 'address', 'created_at'] }, customers: { rw: false, fields: ['name', 'email', 'city', 'orders_count', 'total_spent_cents', 'created_at'] }, + reviews: { rw: true, fields: ['product_slug', 'name', 'rating(1-5)', 'text', 'approved(0|1)', 'created_at'] }, + abandoned_carts: { rw: false, fields: ['email', 'items[]', 'total_cents', 'recovered(0|1)', 'reminded(0|1)', 'created_at'] }, }; export function listResource(name) { @@ -41,6 +43,8 @@ export function listResource(name) { case 'orders': return store.listOrders(); case 'customers': return store.listCustomers(); case 'settings': return store.getSettings(); + case 'reviews': return store.listReviews(); + case 'abandoned_carts': return store.listAbandonedCarts(); default: return null; } } @@ -54,6 +58,7 @@ export function getResource(name, id) { case 'shipping_zones': return store.getShippingZoneById(id); case 'orders': return store.getOrderById(id); case 'customers': return store.getCustomerById(id); + case 'reviews': return store.getReviewById(id); default: return null; } } @@ -83,6 +88,17 @@ export function upsertResource(name, body) { 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 === 'reviews') { + if (body.id) { + if (body.approved !== undefined) store.setReviewApproved(body.id, body.approved ? 1 : 0); + return store.getReviewById(body.id); + } + const res = store.addReview({ product_slug: body.product_slug, name: body.name, rating: body.rating, text: body.text }); + if (!res.ok) throw new Error(res.error || 'Ungültige Bewertung'); + // optional direkt freigeben + if (body.approved) store.setReviewApproved(res.id, 1); + return store.getReviewById(res.id); + } if (name === 'shipping_zones') { if (body.id) { store.updateShippingZone(body.id, body); return store.getShippingZoneById(body.id); } const id = store.createShippingZone(body); return store.getShippingZoneById(id); @@ -103,6 +119,7 @@ export function deleteResource(name, id) { case 'popups': store.deletePopup(id); return true; case 'discounts': store.deleteDiscount(id); return true; case 'shipping_zones': store.deleteShippingZone(id); return true; + case 'reviews': store.deleteReview(id); return true; default: throw new Error('Ressource nicht löschbar: ' + name); } } @@ -125,7 +142,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.2.0', + version: '2.3.0', auth: 'Authorization: Bearer ', base_url: origin || '', resources: RESOURCES, @@ -140,7 +157,9 @@ export function manifest(origin) { 'discounts.value: bei percent 1–100, bei fixed in Cent, bei freeshipping ignoriert. Codes werden case-insensitiv geprüft.', 'shipping_zones.countries: CSV von ISO-Codes (DE, AT,CH) oder "EU" für alle EU-Länder. free_over_cents nullable.', 'products.mwst: 0, 7 oder 19. base_amount/base_unit/base_price_per ergeben den Grundpreis (PAngV), z. B. 250 + g + kg.', - 'Feature-Flags & payment_provider sind Settings-Keys (über /api/admin/settings setzbar): feature_newsletter, feature_accounts, …, payment_provider (mollie|stripe|demo).', + 'Feature-Flags & payment_provider sind Settings-Keys (über /api/admin/settings setzbar): feature_newsletter, feature_accounts, feature_reviews, feature_wishlist, feature_abandoned_cart, feature_search, payment_provider (mollie|stripe|demo).', + 'reviews: POST ohne id legt eine Bewertung an (approved=0); POST mit { id, approved:true|false } moderiert. rating 1–5.', + 'abandoned_carts ist nur lesbar; Versand-Trigger ist POST /api/cron/abandoned (Bearer CRON_TOKEN).', ], }; } diff --git a/src/lib/auth.js b/src/lib/auth.js index ecd5ef5..42ceacb 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -88,8 +88,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', 'rabatte', 'inhalte', 'versandzonen', 'einstellungen', 'nutzer', 'audit'], - redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'rabatte', 'analytics', 'versandzonen'], + owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'rabatte', 'inhalte', 'versandzonen', 'bewertungen', 'einstellungen', 'nutzer', 'audit'], + redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'rabatte', 'analytics', 'versandzonen', 'bewertungen'], versand: ['bestellungen'], }; export function canAccess(role, section) { diff --git a/src/lib/customer-auth.js b/src/lib/customer-auth.js new file mode 100644 index 0000000..d25be69 --- /dev/null +++ b/src/lib/customer-auth.js @@ -0,0 +1,71 @@ +// hd-commerce — Kunden-Session (getrennt von der Admin-Auth). +// Eigener Cookie-Name (hdc_customer), eigenes signiertes HMAC-Token. NICHT die Admin-Session. +import { createHmac, timingSafeEqual, randomBytes } from 'node:crypto'; +import { getSetting, setSetting, getCustomerAccountById } from './store.js'; + +function resolveSecret() { + const env = (process.env.SESSION_SECRET || '').trim(); + // Eigenes, vom Admin getrenntes Geheimnis ableiten, damit Admin- und Kunden-Token + // niemals gegeneinander gültig sind (anderer HMAC-Key). + let base = env && env.length >= 16 ? env : null; + if (!base) { + try { + let sec = getSetting('customer_session_secret'); + if (!sec) { sec = randomBytes(32).toString('hex'); setSetting('customer_session_secret', sec); } + base = sec; + } catch { base = randomBytes(32).toString('hex'); } + } + return createHmac('sha256', base).update('hdc-customer-session').digest('hex'); +} +const SECRET = resolveSecret(); +export const CUSTOMER_COOKIE = 'hdc_customer'; + +function b64url(buf) { return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } +function b64urlDecode(str) { return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); } + +export function signCustomer(cid, maxAgeSeconds) { + const exp = Math.floor(Date.now() / 1000) + (maxAgeSeconds || 60 * 60 * 24 * 30); + const payload = b64url(JSON.stringify({ cid: Number(cid), exp })); + const sig = b64url(createHmac('sha256', SECRET).update(payload).digest()); + return payload + '.' + sig; +} +export function verifyCustomerToken(token) { + if (!token || typeof token !== 'string' || !token.includes('.')) return null; + const [payload, sig] = token.split('.'); + if (!payload || !sig) return null; + const expected = b64url(createHmac('sha256', SECRET).update(payload).digest()); + try { + const a = Buffer.from(sig), b = Buffer.from(expected); + if (a.length !== b.length || !timingSafeEqual(a, b)) return null; + } catch { return null; } + let data; + try { data = JSON.parse(b64urlDecode(payload).toString('utf8')); } catch { return null; } + if (!data || !data.cid || !data.exp) return null; + if (data.exp < Math.floor(Date.now() / 1000)) return null; + return data; +} + +const cookieSecure = () => (process.env.PUBLIC_BASE_URL || '').startsWith('https') || process.env.COOKIE_SECURE === '1'; +export function buildCustomerCookie(token) { + const parts = [`${CUSTOMER_COOKIE}=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=' + (60 * 60 * 24 * 30)]; + if (cookieSecure()) parts.push('Secure'); + return parts.join('; '); +} +export function clearCustomerCookie() { + return `${CUSTOMER_COOKIE}=; Path=/; HttpOnly; SameSite=Lax;${cookieSecure() ? ' Secure;' : ''} Max-Age=0`; +} + +function parseCookies(request) { + const h = request.headers.get('cookie') || ''; + const out = {}; + h.split(';').forEach(p => { const i = p.indexOf('='); if (i > -1) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); }); + return out; +} +// Aktuell eingeloggter Kunde (oder null). Liest NUR den Kunden-Cookie. +export function currentCustomer(request) { + const token = parseCookies(request)[CUSTOMER_COOKIE]; + const data = verifyCustomerToken(token); + if (!data) return null; + const c = getCustomerAccountById(data.cid); + return c || null; +} diff --git a/src/lib/mailer.js b/src/lib/mailer.js index 48535ac..4e8b17c 100644 --- a/src/lib/mailer.js +++ b/src/lib/mailer.js @@ -118,3 +118,45 @@ export async function sendOrderConfirmation(order) { const html = orderConfirmationHtml(order); return sendMail({ to: order.email, subject, html, type: 'order_confirmation' }); } + +// Gebrandete Warenkorb-Erinnerung (Abandoned-Cart). cart: { email, items, total_cents } +export function abandonedCartHtml(cart, baseUrl) { + const s = getSettings(); + const shopName = s.shop_name || 'hd-commerce'; + const accent = s.brand_accent || '#b8566a'; + const items = Array.isArray(cart.items) ? cart.items : []; + const shopUrl = (baseUrl ? String(baseUrl).replace(/\/$/, '') : '') + '/warenkorb'; + const rows = items.map(i => ` + + ${esc(i.name)}${i.size && i.size !== 'One Size' ? ' (' + esc(i.size) + ')' : ''} + ${esc(i.qty || 1)}× + ${esc(formatPrice((Number(i.priceCents) || 0) * (Number(i.qty) || 1)))} + `).join(''); + return ` +
+
+
+
${esc(shopName)}
+
Du hast etwas vergessen
+
+
+

Dein Warenkorb wartet noch auf dich. Diese Artikel hast du dir angesehen:

+ ${rows}
+ + +
Summe${esc(formatPrice(Number(cart.total_cents) || 0))}
+
+

Falls du die Bestellung schon abgeschlossen hast, kannst du diese E-Mail ignorieren.

+
+
+
© ${new Date().getFullYear()} ${esc(shopName)} · powered by hd-commerce
+
`; +} + +export async function sendAbandonedCartReminder(cart, baseUrl) { + if (!cart || !cart.email) return { ok: false, status: 'no-recipient' }; + const s = getSettings(); + const subject = `Dein Warenkorb wartet · ${s.shop_name || 'hd-commerce'}`; + const html = abandonedCartHtml(cart, baseUrl); + return sendMail({ to: cart.email, subject, html, type: 'abandoned_cart' }); +} diff --git a/src/lib/store-sqlite.js b/src/lib/store-sqlite.js index 89ee7bc..ca4bc95 100644 --- a/src/lib/store-sqlite.js +++ b/src/lib/store-sqlite.js @@ -850,3 +850,194 @@ export function logEmail({ recipient = '', subject = '', html = '', type = 'gene } export const listEmailLog = (limit = 100) => db.prepare('SELECT * FROM email_log ORDER BY id DESC LIMIT ?').all(Number(limit) || 100); export const getEmailLogById = (id) => db.prepare('SELECT * FROM email_log WHERE id=?').get(Number(id)); + +// ===================================================================== +// v2.3 — Feature-Module: Suche, Kundenkonten/Adressen, Bewertungen, Abandoned-Cart +// ===================================================================== + +// ---------- neue Tabellen + Spalten ---------- +db.exec(` +CREATE TABLE IF NOT EXISTS customer_addresses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL, + name TEXT DEFAULT '', strasse TEXT DEFAULT '', plz TEXT DEFAULT '', ort TEXT DEFAULT '', + land TEXT DEFAULT 'DE', is_default INTEGER DEFAULT 0, created_at TEXT +); +CREATE TABLE IF NOT EXISTS reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, product_slug TEXT NOT NULL, name TEXT DEFAULT '', + rating INTEGER DEFAULT 5, text TEXT DEFAULT '', approved INTEGER DEFAULT 0, created_at TEXT +); +CREATE TABLE IF NOT EXISTS abandoned_carts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT, items_json TEXT DEFAULT '[]', + total_cents INTEGER DEFAULT 0, recovered INTEGER DEFAULT 0, reminded INTEGER DEFAULT 0, created_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_reviews_slug ON reviews(product_slug); +CREATE INDEX IF NOT EXISTS idx_addr_customer ON customer_addresses(customer_id); +`); +// Kundenkonten: Passwort-Hash + Zuordnung Bestellung→Kunde +ensureColumn('customers', 'pass_hash', "pass_hash TEXT"); +ensureColumn('customers', 'pass_salt', "pass_salt TEXT"); +ensureColumn('orders', 'customer_id', "customer_id INTEGER"); + +// ---------- Volltextsuche (feature_search) ---------- +// LIKE/instr-basiert (case-insensitiv, ohne FTS-Tokenizer-Abhängigkeit), durchsucht +// Name, Kurzname, Beschreibung, Material und Kategorie. +export function searchProducts(query, limit = 60) { + const q = String(query || '').trim().toLowerCase(); + if (!q) return []; + const like = '%' + q.replace(/[%_]/g, m => '\\' + m) + '%'; + const rows = db.prepare(` + SELECT * FROM products + WHERE lower(name) LIKE ? ESCAPE '\\' + OR lower(COALESCE(shortName,'')) LIKE ? ESCAPE '\\' + OR lower(COALESCE(desc,'')) LIKE ? ESCAPE '\\' + OR lower(COALESCE(material,'')) LIKE ? ESCAPE '\\' + OR lower(COALESCE(category,'')) LIKE ? ESCAPE '\\' + ORDER BY + CASE WHEN lower(name) LIKE ? ESCAPE '\\' THEN 0 ELSE 1 END, + featured DESC, sort, id + LIMIT ?`).all(like, like, like, like, like, like, Number(limit) || 60); + return rows.map(P); +} + +// ---------- Kundenkonten (feature_accounts) ---------- +const CUST = (r) => r && ({ id: r.id, name: r.name, email: r.email, city: r.city, created_at: r.created_at, has_password: !!r.pass_hash }); +export const getCustomerByEmail = (email) => db.prepare('SELECT * FROM customers WHERE LOWER(email)=LOWER(?)').get(String(email || '').trim()); + +export function registerCustomer({ name, email, password }) { + const e = String(email || '').toLowerCase().trim(); + if (!e || !/.+@.+\..+/.test(e)) return { ok: false, error: 'Bitte gültige E-Mail angeben.' }; + if (!password || String(password).length < 6) return { ok: false, error: 'Passwort muss mindestens 6 Zeichen haben.' }; + const existing = getCustomerByEmail(e); + if (existing && existing.pass_hash) return { ok: false, error: 'Für diese E-Mail existiert bereits ein Konto.' }; + const { pass_hash, pass_salt } = hashPassword(password); + const now = new Date().toISOString(); + if (existing) { + db.prepare('UPDATE customers SET name=COALESCE(NULLIF(?,\'\'),name), pass_hash=?, pass_salt=? WHERE id=?') + .run(name || existing.name || '', pass_hash, pass_salt, existing.id); + return { ok: true, id: existing.id }; + } + const r = db.prepare('INSERT INTO customers (name,email,city,created_at,pass_hash,pass_salt) VALUES (?,?,?,?,?,?)') + .run(name || e, e, '', now, pass_hash, pass_salt); + return { ok: true, id: r.lastInsertRowid }; +} + +export function verifyCustomer(email, password) { + const c = getCustomerByEmail(email); + if (!c || !c.pass_hash) return null; + if (!verifyPassword(password, c.pass_hash, c.pass_salt)) return null; + return CUST(c); +} +export const getCustomerAccountById = (id) => CUST(db.prepare('SELECT * FROM customers WHERE id=?').get(Number(id))); + +// Adressbuch +const ADDR = (r) => r && ({ ...r, is_default: !!r.is_default }); +export const listCustomerAddresses = (customerId) => + db.prepare('SELECT * FROM customer_addresses WHERE customer_id=? ORDER BY is_default DESC, id DESC').all(Number(customerId)).map(ADDR); +export const getCustomerAddress = (id, customerId) => + ADDR(db.prepare('SELECT * FROM customer_addresses WHERE id=? AND customer_id=?').get(Number(id), Number(customerId))); +export const defaultAddress = (customerId) => + ADDR(db.prepare('SELECT * FROM customer_addresses WHERE customer_id=? ORDER BY is_default DESC, id DESC LIMIT 1').get(Number(customerId))); +export function addCustomerAddress(customerId, d) { + const cid = Number(customerId); + const isDefault = d.is_default ? 1 : 0; + if (isDefault) db.prepare('UPDATE customer_addresses SET is_default=0 WHERE customer_id=?').run(cid); + const haveAny = db.prepare('SELECT COUNT(*) c FROM customer_addresses WHERE customer_id=?').get(cid).c; + const r = db.prepare(`INSERT INTO customer_addresses (customer_id,name,strasse,plz,ort,land,is_default,created_at) + VALUES (?,?,?,?,?,?,?,?)`).run(cid, d.name || '', d.strasse || '', d.plz || '', d.ort || '', + String(d.land || 'DE').toUpperCase(), (isDefault || haveAny === 0) ? 1 : 0, new Date().toISOString()); + return r.lastInsertRowid; +} +export function deleteCustomerAddress(id, customerId) { + db.prepare('DELETE FROM customer_addresses WHERE id=? AND customer_id=?').run(Number(id), Number(customerId)); + return true; +} +export function setDefaultAddress(id, customerId) { + const cid = Number(customerId); + db.prepare('UPDATE customer_addresses SET is_default=0 WHERE customer_id=?').run(cid); + db.prepare('UPDATE customer_addresses SET is_default=1 WHERE id=? AND customer_id=?').run(Number(id), cid); + return true; +} +export const listOrdersForCustomer = (customerId, email) => { + const cid = Number(customerId); + return db.prepare('SELECT * FROM orders WHERE customer_id=? OR (? <> \'\' AND LOWER(email)=LOWER(?)) ORDER BY datetime(created_at) DESC, id DESC') + .all(cid, String(email || ''), String(email || '')).map(O); +}; +export function attachOrderToCustomer(orderId, customerId) { + db.prepare('UPDATE orders SET customer_id=? WHERE id=?').run(Number(customerId), Number(orderId)); + return true; +} + +// ---------- Bewertungen (feature_reviews) ---------- +const RV = (r) => r && ({ ...r, approved: !!r.approved }); +export function addReview({ product_slug, name, rating, text }) { + const slug = String(product_slug || '').trim(); + if (!slug) return { ok: false, error: 'Produkt fehlt.' }; + const raw = Math.round(Number(rating)); + if (!Number.isFinite(raw) || raw < 1 || raw > 5) return { ok: false, error: 'Bitte 1 bis 5 Sterne wählen.' }; + const r = raw; + const res = db.prepare('INSERT INTO reviews (product_slug,name,rating,text,approved,created_at) VALUES (?,?,?,?,0,?)') + .run(slug, String(name || '').trim().slice(0, 80) || 'Anonym', r, String(text || '').trim().slice(0, 2000), new Date().toISOString()); + return { ok: true, id: res.lastInsertRowid }; +} +export const listApprovedReviews = (slug) => + db.prepare('SELECT * FROM reviews WHERE product_slug=? AND approved=1 ORDER BY id DESC').all(String(slug || '')).map(RV); +export function reviewSummary(slug) { + const row = db.prepare('SELECT COUNT(*) c, COALESCE(AVG(rating),0) avg FROM reviews WHERE product_slug=? AND approved=1').get(String(slug || '')); + return { count: row.c, average: row.c ? Math.round(row.avg * 10) / 10 : 0 }; +} +export const listAllReviews = (filter = 'all') => { + let where = ''; + if (filter === 'pending') where = 'WHERE approved=0'; + else if (filter === 'approved') where = 'WHERE approved=1'; + return db.prepare(`SELECT * FROM reviews ${where} ORDER BY approved ASC, id DESC`).all().map(RV); +}; +export const getReviewById = (id) => RV(db.prepare('SELECT * FROM reviews WHERE id=?').get(Number(id))); +export const countPendingReviews = () => db.prepare('SELECT COUNT(*) c FROM reviews WHERE approved=0').get().c; +export function setReviewApproved(id, approved) { + db.prepare('UPDATE reviews SET approved=? WHERE id=?').run(approved ? 1 : 0, Number(id)); + return true; +} +export const deleteReview = (id) => db.prepare('DELETE FROM reviews WHERE id=?').run(Number(id)); +// API-Mapper: Upsert/CRUD über admin-api +export const listReviews = () => db.prepare('SELECT * FROM reviews ORDER BY id DESC').all().map(RV); + +// ---------- Abandoned Carts (feature_abandoned_cart) ---------- +const AC = (r) => r && ({ ...r, items: (() => { try { return JSON.parse(r.items_json || '[]'); } catch { return []; } })(), recovered: !!r.recovered, reminded: !!r.reminded }); +export function captureAbandonedCart({ email, items, total_cents }) { + const e = String(email || '').toLowerCase().trim(); + if (!e || !/.+@.+\..+/.test(e)) return { ok: false, error: 'E-Mail fehlt.' }; + const its = Array.isArray(items) ? items : []; + if (!its.length) return { ok: false, error: 'Warenkorb leer.' }; + const total = Math.round(Number(total_cents) || its.reduce((s, i) => s + (Number(i.priceCents) || 0) * (Number(i.qty) || 1), 0)); + // Offene (nicht recovered) Karte derselben E-Mail aktualisieren statt duplizieren. + const open = db.prepare("SELECT * FROM abandoned_carts WHERE LOWER(email)=LOWER(?) AND recovered=0 AND reminded=0 ORDER BY id DESC LIMIT 1").get(e); + if (open) { + db.prepare('UPDATE abandoned_carts SET items_json=?, total_cents=?, created_at=? WHERE id=?') + .run(JSON.stringify(its), total, new Date().toISOString(), open.id); + return { ok: true, id: open.id, updated: true }; + } + const r = db.prepare('INSERT INTO abandoned_carts (email,items_json,total_cents,recovered,reminded,created_at) VALUES (?,?,?,0,0,?)') + .run(e, JSON.stringify(its), total, new Date().toISOString()); + return { ok: true, id: r.lastInsertRowid }; +} +export const listAbandonedCarts = (limit = 100) => db.prepare('SELECT * FROM abandoned_carts ORDER BY id DESC LIMIT ?').all(Number(limit) || 100).map(AC); +export function abandonedCartStats() { + const total = db.prepare('SELECT COUNT(*) c FROM abandoned_carts').get().c; + const open = db.prepare('SELECT COUNT(*) c FROM abandoned_carts WHERE recovered=0 AND reminded=0').get().c; + const reminded = db.prepare('SELECT COUNT(*) c FROM abandoned_carts WHERE reminded=1').get().c; + const recovered = db.prepare('SELECT COUNT(*) c FROM abandoned_carts WHERE recovered=1').get().c; + return { total, open, reminded, recovered }; +} +// Karten älter als olderThanMinutes, weder recovered noch reminded. +export function dueAbandonedCarts(olderThanMinutes = 30) { + const cutoff = new Date(Date.now() - Math.max(0, Number(olderThanMinutes) || 0) * 60000).toISOString(); + return db.prepare("SELECT * FROM abandoned_carts WHERE recovered=0 AND reminded=0 AND created_at <= ? ORDER BY id ASC").all(cutoff).map(AC); +} +export const markAbandonedReminded = (id) => { db.prepare('UPDATE abandoned_carts SET reminded=1 WHERE id=?').run(Number(id)); return true; }; +// Bei erfolgreicher Bestellung: offene Karten dieser E-Mail als recovered markieren. +export function markCartRecoveredByEmail(email) { + const e = String(email || '').toLowerCase().trim(); + if (!e) return 0; + const r = db.prepare("UPDATE abandoned_carts SET recovered=1 WHERE LOWER(email)=LOWER(?) AND recovered=0").run(e); + return r.changes || 0; +} diff --git a/src/middleware.js b/src/middleware.js index bf6e2da..79ed52e 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -18,7 +18,7 @@ function sectionOf(adminInner) { const map = { '': 'dashboard', 'bestellungen': 'bestellungen', 'produkte': 'produkte', 'kunden': 'kunden', 'analytics': 'analytics', 'marketing': 'marketing', 'rabatte': 'rabatte', 'inhalte': 'inhalte', 'einstellungen': 'einstellungen', - 'nutzer': 'nutzer', 'audit': 'audit', 'versand': 'versandzonen', 'konto': 'dashboard', 'login': 'login', 'logout': 'logout', + 'nutzer': 'nutzer', 'audit': 'audit', 'versand': 'versandzonen', 'bewertungen': 'bewertungen', 'konto': 'dashboard', 'login': 'login', 'logout': 'logout', }; return map[seg] || 'dashboard'; } diff --git a/src/pages/admin/bewertungen/index.astro b/src/pages/admin/bewertungen/index.astro new file mode 100644 index 0000000..9c1335f --- /dev/null +++ b/src/pages/admin/bewertungen/index.astro @@ -0,0 +1,71 @@ +--- +import Admin from '../../../layouts/Admin.astro'; +import { adminBase, currentUser, canAccess } from '../../../lib/auth.js'; +import { feature, listAllReviews, setReviewApproved, deleteReview, getProductBySlug } from '../../../lib/store.js'; + +const base = adminBase(); +const user = currentUser(Astro.request); +// Modul aus → Bereich existiert nicht. +if (!feature('feature_reviews')) return Astro.redirect(base + '/einstellungen'); +if (!user || !canAccess(user.role, 'bewertungen')) return Astro.redirect(base); + +let flash = ''; +if (Astro.request.method === 'POST') { + const f = await Astro.request.formData(); + const action = String(f.get('_action') || ''); + const id = f.get('id'); + if (action === 'approve') { setReviewApproved(id, true); flash = 'Bewertung freigegeben.'; } + else if (action === 'reject') { setReviewApproved(id, false); flash = 'Bewertung abgelehnt (nicht öffentlich).'; } + else if (action === 'delete') { deleteReview(id); flash = 'Bewertung gelöscht.'; } +} + +const url = new URL(Astro.request.url); +const tab = url.searchParams.get('tab') || 'pending'; +const reviews = listAllReviews(tab === 'all' ? 'all' : tab === 'approved' ? 'approved' : 'pending'); +const counts = { + pending: listAllReviews('pending').length, + approved: listAllReviews('approved').length, + all: listAllReviews('all').length, +}; +function stars(n) { return '★★★★★'.slice(0, n) + '☆☆☆☆☆'.slice(0, 5 - n); } +--- + +
+ {flash &&
✓ {flash}
} + + + {reviews.length === 0 ? ( +

Keine Bewertungen in dieser Ansicht.

+ ) : ( +
+ + + + {reviews.map((r) => { + const p = getProductBySlug(r.product_slug); + return ( + + + + + + + + + ); + })} + +
ProduktBewertungTextDatumStatusAktionen
{p ? ({p.shortName || p.name}) : r.product_slug}{stars(r.rating)}
{r.name}
{r.text || }{new Date(r.created_at).toLocaleDateString('de-DE')}{r.approved ? Freigegeben : Wartet} + {!r.approved && (
)} + {r.approved && (
)} + {' '} +
+
+
+ )} +
+
diff --git a/src/pages/admin/einstellungen/index.astro b/src/pages/admin/einstellungen/index.astro index f9c3a9e..440a225 100644 --- a/src/pages/admin/einstellungen/index.astro +++ b/src/pages/admin/einstellungen/index.astro @@ -2,16 +2,16 @@ import Admin from '../../../layouts/Admin.astro'; import { adminBase } from '../../../lib/auth.js'; const base = adminBase(); -import { getSettings, setSetting, resolvePaymentProvider, FEATURE_KEYS, feature } from '../../../lib/store.js'; +import { getSettings, setSetting, resolvePaymentProvider, FEATURE_KEYS, feature, abandonedCartStats, countPendingReviews } from '../../../lib/store.js'; import { mailerStatus } from '../../../lib/mailer.js'; const FEATURE_LABELS = { feature_newsletter: ['Newsletter', 'Newsletter-Popup & Anmeldung im Storefront'], - feature_accounts: ['Kundenkonten', 'Registrierung & Login für Kund:innen (in Vorbereitung)'], - feature_reviews: ['Bewertungen', 'Produktbewertungen (in Vorbereitung)'], - feature_wishlist: ['Merkliste', 'Wunschliste / Merken (in Vorbereitung)'], - feature_abandoned_cart: ['Warenkorb-Erinnerung', 'Abandoned-Cart-Mails (in Vorbereitung)'], - feature_search: ['Suche', 'Produktsuche im Storefront (in Vorbereitung)'], + feature_accounts: ['Kundenkonten', 'Registrierung, Login, Bestellhistorie & Adressbuch für Kund:innen; Adresse im Checkout vorbefüllt'], + feature_reviews: ['Bewertungen', 'Sterne-Bewertungen auf Produktseiten mit Moderation (Freigabe im Admin)'], + feature_wishlist: ['Merkliste', 'Herz-Button auf Produktkarten & Detailseite; Merkliste unter /merkliste (clientseitig)'], + feature_abandoned_cart: ['Warenkorb-Erinnerung', 'Begonnene Checkouts speichern & per Mail erinnern (Cron /api/cron/abandoned)'], + feature_search: ['Suche', 'Volltextsuche im Storefront-Header mit Ergebnisseite /suche'], }; let flash = ''; @@ -47,6 +47,9 @@ const stripeSet = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test((process.env.STRIPE_SE const providerSetting = s.payment_provider || ''; const mail = mailerStatus(); +const acStats = abandonedCartStats(); +const pendingReviews = countPendingReviews(); +const cronToken = (process.env.CRON_TOKEN || '').trim(); ---
@@ -111,6 +114,21 @@ const mail = mailerStatus();
System

Datenbank: SQLite (DB_PATH). Admin-Zugang über Session-Login; Initial-Owner aus ADMIN_EMAIL / ADMIN_PASS. Admin-Pfad über ADMIN_PATH.

+ + {feature('feature_abandoned_cart') && ( +
+
Warenkorb-Erinnerung
+

Gespeicherte Warenkörbe: {acStats.total} · offen {acStats.open} · erinnert {acStats.reminded} · wiederhergestellt {acStats.recovered}

+

Versand-Trigger: POST /api/cron/abandoned (Header Authorization: Bearer <CRON_TOKEN> oder ?token=). {cronToken ? 'CRON_TOKEN gesetzt.' : 'CRON_TOKEN noch nicht gesetzt — Endpoint bleibt gesperrt.'}

+
+ )} + + {feature('feature_reviews') && pendingReviews > 0 && ( +
+
Bewertungen
+

{pendingReviews} Bewertung(en) warten auf Freigabe — jetzt prüfen.

+
+ )}
diff --git a/src/pages/api/cart-capture.js b/src/pages/api/cart-capture.js new file mode 100644 index 0000000..900c9bf --- /dev/null +++ b/src/pages/api/cart-capture.js @@ -0,0 +1,14 @@ +import { captureAbandonedCart, feature } 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' } }); } + +// Wird beim Checkout-Start aufgerufen, sobald eine E-Mail vorliegt. Speichert den Warenkorb +// serverseitig, damit eine Erinnerungsmail verschickt werden kann. +export async function POST({ request }) { + if (!feature('feature_abandoned_cart')) return json({ ok: false, error: 'Modul deaktiviert.' }, 404); + let b; + try { b = await request.json(); } catch { return json({ ok: false, error: 'Bad request' }, 400); } + const res = captureAbandonedCart({ email: b.email, items: b.items, total_cents: b.total_cents }); + if (!res.ok) return json(res, 400); + return json({ ok: true, id: res.id }); +} diff --git a/src/pages/api/checkout.js b/src/pages/api/checkout.js index 586b35b..52a49c1 100644 --- a/src/pages/api/checkout.js +++ b/src/pages/api/checkout.js @@ -1,7 +1,9 @@ import { createOrder, getSetting, validateDiscount, redeemDiscount, bestAutoDiscount, shippingFor, taxFromGross, getProductBySlug, markOrderPaid, getOrderById, setOrderPayment, + feature, getCustomerByEmail, attachOrderToCustomer, markCartRecoveredByEmail, } from '../../lib/store.js'; +import { currentCustomer } from '../../lib/customer-auth.js'; import { createPayment } from '../../lib/payments.js'; import { sendOrderConfirmation } from '../../lib/mailer.js'; export const prerender = false; @@ -93,6 +95,13 @@ export async function POST({ request }) { redeemDiscount(discount.id, discount.code, email, order.id, discount.freeShipping ? 0 : discountCents); } + // Kundenkonten: Bestellung dem eingeloggten Konto (oder dem Konto zur E-Mail) zuordnen. + if (feature('feature_accounts')) { + let cust = currentCustomer(request); + if (!cust && email) { const byEmail = getCustomerByEmail(email); if (byEmail) cust = byEmail; } + if (cust) { try { attachOrderToCustomer(order.id, cust.id); } catch {} } + } + const origin = publicBase(request); const returnUrl = `${origin}/bestellung-erfolgreich?order=${order.number}`; const pay = await createPayment({ @@ -107,6 +116,7 @@ export async function POST({ request }) { if (pay.provider === 'demo') { const res = markOrderPaid(order.id, { payment_provider: 'demo', payment_id: '' }); if (res.changed) { try { await sendOrderConfirmation(res.order); } catch {} } + if (feature('feature_abandoned_cart') && email) { try { markCartRecoveredByEmail(email); } catch {} } } else if (pay.paymentId) { // Payment-ID an der Order vermerken, damit der Webhook sie zuordnen kann. setOrderPayment(order.id, { payment_id: pay.paymentId, payment_provider: pay.provider }); diff --git a/src/pages/api/cron/abandoned.js b/src/pages/api/cron/abandoned.js new file mode 100644 index 0000000..807fdde --- /dev/null +++ b/src/pages/api/cron/abandoned.js @@ -0,0 +1,48 @@ +import { dueAbandonedCarts, markAbandonedReminded, feature } from '../../../lib/store.js'; +import { sendAbandonedCartReminder } from '../../../lib/mailer.js'; +import { timingSafeEqual } from 'node:crypto'; +export const prerender = false; +function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); } + +function publicBase(request) { + const env = (process.env.PUBLIC_BASE_URL || '').trim().replace(/\/$/, ''); + if (env) return env; + const proto = request.headers.get('x-forwarded-proto') || 'https'; + const host = request.headers.get('x-forwarded-host') || request.headers.get('host'); + if (host) return `${proto}://${host}`; + try { return new URL(request.url).origin; } catch { return ''; } +} + +function tokenOk(request, url) { + const token = (process.env.CRON_TOKEN || '').trim(); + if (!token) return false; // ohne CRON_TOKEN bleibt der Endpoint gesperrt + const hdr = request.headers.get('authorization') || ''; + const m = hdr.match(/^Bearer\s+(.+)$/i); + const provided = (m ? m[1].trim() : '') || (url.searchParams.get('token') || '').trim(); + if (!provided) return false; + const a = Buffer.from(provided), b = Buffer.from(token); + return a.length === b.length && timingSafeEqual(a, b); +} + +async function run({ request, url }) { + if (!feature('feature_abandoned_cart')) return json({ ok: false, error: 'Modul deaktiviert.' }, 404); + if (!tokenOk(request, url)) return json({ ok: false, error: 'Unauthorized' }, 401); + const rawMin = url.searchParams.get('minutes'); + const minSrc = rawMin != null && rawMin !== '' ? rawMin : (process.env.ABANDONED_AFTER_MINUTES || '30'); + const parsedMin = parseInt(minSrc, 10); + const minutes = Math.max(0, Number.isFinite(parsedMin) ? parsedMin : 30); + const base = publicBase(request); + const due = dueAbandonedCarts(minutes); + let sent = 0; + for (const cart of due) { + try { + const res = await sendAbandonedCartReminder(cart, base); + markAbandonedReminded(cart.id); + if (res && res.ok) sent++; + } catch { markAbandonedReminded(cart.id); } + } + return json({ ok: true, due: due.length, reminded: sent, older_than_minutes: minutes }); +} + +export const POST = (ctx) => run({ request: ctx.request, url: new URL(ctx.request.url) }); +export const GET = (ctx) => run({ request: ctx.request, url: new URL(ctx.request.url) }); diff --git a/src/pages/api/payments/webhook.js b/src/pages/api/payments/webhook.js index 11953ce..90661db 100644 --- a/src/pages/api/payments/webhook.js +++ b/src/pages/api/payments/webhook.js @@ -28,7 +28,13 @@ export async function POST({ request }) { } if (order) { const res = markOrderPaid(order.id, { payment_id: paymentId, payment_provider: 'mollie' }); - if (res.changed) { try { await sendOrderConfirmation(res.order); } catch {} } + if (res.changed) { + try { await sendOrderConfirmation(res.order); } catch {} + try { + const { feature, markCartRecoveredByEmail } = await import('../../../lib/store.js'); + if (feature('feature_abandoned_cart') && res.order && res.order.email) markCartRecoveredByEmail(res.order.email); + } catch {} + } } } } catch (e) { diff --git a/src/pages/api/review.js b/src/pages/api/review.js new file mode 100644 index 0000000..84849cd --- /dev/null +++ b/src/pages/api/review.js @@ -0,0 +1,12 @@ +import { addReview, feature } 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' } }); } + +export async function POST({ request }) { + if (!feature('feature_reviews')) return json({ ok: false, error: 'Bewertungen sind deaktiviert.' }, 404); + let b; + try { b = await request.json(); } catch { return json({ ok: false, error: 'Bad request' }, 400); } + const res = addReview({ product_slug: b.product_slug, name: b.name, rating: b.rating, text: b.text }); + if (!res.ok) return json(res, 400); + return json({ ok: true }); +} diff --git a/src/pages/checkout.astro b/src/pages/checkout.astro index 089463b..ac5dc00 100644 --- a/src/pages/checkout.astro +++ b/src/pages/checkout.astro @@ -1,6 +1,7 @@ --- import Base from '../layouts/Base.astro'; -import { getSetting, listActiveShippingZones, resolvePaymentProvider } from '../lib/store.js'; +import { getSetting, listActiveShippingZones, resolvePaymentProvider, feature, defaultAddress } from '../lib/store.js'; +import { currentCustomer } from '../lib/customer-auth.js'; const currency = getSetting('currency', 'EUR'); const zones = listActiveShippingZones(); // Länder-Optionen aus aktiven Zonen ableiten (ISO-Codes + Klartext). @@ -19,6 +20,15 @@ countrySet.sort((a, b) => (a.code === 'DE' ? -1 : b.code === 'DE' ? 1 : a.name.l const pp = resolvePaymentProvider(); const providerLabel = { mollie: 'Mollie', stripe: 'Stripe', demo: 'Demo' }[pp.provider] || pp.provider; const demoMode = pp.provider === 'demo'; + +// Eingeloggter Kunde? Adresse vorbefüllen (Konto-Modul). +const accountsOn = feature('feature_accounts'); +const customer = accountsOn ? currentCustomer(Astro.request) : null; +const pre = customer ? (defaultAddress(customer.id) || {}) : {}; +const preName = (pre.name || customer?.name || '').trim(); +const preFirst = preName ? preName.split(' ')[0] : ''; +const preLast = preName ? preName.split(' ').slice(1).join(' ') : ''; +const abandonedOn = feature('feature_abandoned_cart'); ---
@@ -27,13 +37,14 @@ const demoMode = pp.provider === 'demo';

Kontakt & Lieferadresse

+ {accountsOn && !customer && (

Bereits Kund:in? Anmelden für vorausgefüllte Adresse.

)}
-
-
-
-
-
-
+
+
+
+
+
+
+ + + {q &&

{results.length} {results.length === 1 ? 'Treffer' : 'Treffer'}

} +
+ + +
+
+ {q && results.length === 0 ? ( +
+

Keine Treffer

+

Für „{q}" haben wir nichts gefunden. Versuch es mit einem anderen Begriff oder stöbere im Shop.

+ Zum Shop +
+ ) : !q ? ( +

Gib einen Suchbegriff ein

Durchsuche unser Sortiment nach Name, Kategorie oder Beschreibung.

+ ) : ( + + )} +
+
+ diff --git a/src/styles/admin.css b/src/styles/admin.css index 4e2349f..860dc3f 100644 --- a/src/styles/admin.css +++ b/src/styles/admin.css @@ -238,3 +238,6 @@ .s-feed-row .t{color:var(--s-faint);margin-left:auto;font-size:12px;white-space:nowrap} @keyframes fade{from{opacity:0}to{opacity:1}} @keyframes pop{from{opacity:0;transform:translateY(-6px) scale(.98)}to{opacity:1;transform:none}} + +/* v2.3 — Bewertungs-Zähler in der Nav */ +.s-nav-badge{margin-left:auto;min-width:18px;height:18px;padding:0 5px;border-radius:999px;background:var(--accent);color:#fff;font-size:11px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;line-height:1} diff --git a/src/styles/global.css b/src/styles/global.css index accca7a..04de6eb 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -262,3 +262,78 @@ p{margin:0 0 1rem} .blk-cta-box .btn-primary:hover{background:rgba(255,255,255,.9)} .blk-html{padding:8px 0} @media(max-width:760px){.blk-feat-grid{grid-template-columns:1fr}.blk-gal-grid{grid-template-columns:repeat(2,1fr)!important}} + +/* ============ v2.3 Feature-Module: Suche, Merkliste, Konto, Bewertungen ============ */ +/* Header-Suche */ +.head-search{display:flex;align-items:center;gap:8px;background:var(--sunken);border:1px solid var(--border);border-radius:999px;padding:7px 14px;transition:.15s;max-width:220px} +.head-search:focus-within{border-color:var(--accent);background:var(--surface)} +.head-search svg{width:18px;height:18px;color:var(--faint);flex-shrink:0} +.head-search input{border:none;background:transparent;outline:none;font-size:14px;color:var(--ink);width:100%;min-width:0} +.search-page-form{display:flex;gap:10px;margin-top:18px;max-width:540px} +.search-page-form input{flex:1;padding:13px 16px;border:1px solid var(--border-2);border-radius:var(--radius-sm);font-size:15px;background:var(--surface)} +.search-page-form input:focus{outline:none;border-color:var(--accent)} +@media(max-width:760px){.head-search{display:none}} + +/* Merkliste / Wunschliste */ +.wish-badge{position:absolute;top:4px;right:4px;min-width:18px;height:18px;padding:0 4px;border-radius:999px;background:var(--accent);color:#fff;font-size:11px;font-weight:700;display:none;align-items:center;justify-content:center;line-height:1} +.wish-badge.show{display:flex} +.prod-card-wrap{position:relative;display:flex} +.prod-card-wrap .prod-card{flex:1} +.wish-btn{position:absolute;top:12px;right:12px;z-index:3;width:38px;height:38px;border-radius:999px;border:1px solid var(--border);background:rgba(255,255,255,.92);color:var(--subtle);display:grid;place-items:center;cursor:pointer;transition:.15s;backdrop-filter:blur(4px)} +.wish-btn:hover{color:var(--accent);border-color:var(--accent);transform:scale(1.06)} +.wish-btn svg{width:19px;height:19px;fill:none} +.wish-btn.active{color:var(--accent);border-color:var(--accent)} +.wish-btn.active svg{fill:var(--accent)} +.wish-btn-lg{position:static;width:54px;height:auto;border-radius:var(--radius-sm);flex-shrink:0} +.wish-btn-lg svg{width:22px;height:22px} + +/* Konto / Auth */ +.auth-card{max-width:440px;margin:56px auto;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:36px;box-shadow:var(--shadow)} +.auth-card h1{margin-bottom:6px} +.auth-error{background:color-mix(in srgb,#b3261e 10%,white);color:#b3261e;border:1px solid color-mix(in srgb,#b3261e 30%,white);padding:11px 14px;border-radius:var(--radius-sm);font-size:14px;margin-bottom:18px} +.auth-flash{background:color-mix(in srgb,var(--accent) 12%,white);color:var(--accent-dark);border:1px solid color-mix(in srgb,var(--accent) 28%,white);padding:11px 14px;border-radius:var(--radius-sm);font-size:14px;margin:18px 0} +.konto-head{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;flex-wrap:wrap;padding:44px 0 8px;border-bottom:1px solid var(--border);margin-bottom:8px} +.konto-orders{display:flex;flex-direction:column;gap:12px} +.konto-order{display:grid;grid-template-columns:auto 1fr auto;grid-template-areas:"num status total" "date items total";gap:4px 16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:16px 20px;align-items:center} +.ko-top{grid-area:num;display:flex;gap:10px;align-items:center} +.ko-num{font-weight:700;color:var(--ink)} +.ko-status{font-size:12px;font-weight:700;padding:3px 9px;border-radius:999px;background:var(--sunken);color:var(--subtle)} +.ko-status.ko-paid,.ko-status.ko-fulfilled{background:color-mix(in srgb,#2e7d32 14%,white);color:#2e7d32} +.ko-status.ko-pending{background:color-mix(in srgb,#b8860b 16%,white);color:#8a6d00} +.ko-date{grid-area:date;font-size:13px;color:var(--faint)} +.ko-items{grid-area:items;font-size:14px;color:var(--subtle)} +.ko-total{grid-area:total;font-weight:700;font-size:17px;color:var(--ink);white-space:nowrap} +.konto-addr-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px;margin-bottom:20px} +.konto-addr{position:relative;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:18px 20px} +.konto-addr.is-default{border-color:var(--accent)} +.addr-default-badge{position:absolute;top:14px;right:14px;font-size:11px;font-weight:700;color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,white);padding:3px 9px;border-radius:999px} +.addr-name{font-weight:700;color:var(--ink);margin-bottom:6px} +.addr-lines{font-size:14px;color:var(--subtle);line-height:1.55} +.addr-actions{margin-top:14px;display:flex;gap:14px} +.addr-link{background:none;border:none;padding:0;font-size:13px;color:var(--accent);cursor:pointer;font-weight:600} +.addr-link.danger{color:#b3261e} +.addr-add{margin-top:8px} +.addr-add summary{cursor:pointer;font-weight:600;color:var(--accent);display:inline-block;padding:8px 0} +.addr-form{margin-top:14px;max-width:520px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:22px} + +/* Bewertungen (PDP) */ +.pdp-rating{font-size:15px} +.rev-summary{margin-bottom:24px} +.rev-avg{display:flex;align-items:center;gap:12px;flex-wrap:wrap} +.rev-avg-num{font-family:var(--serif);font-size:42px;color:var(--ink);line-height:1} +.rev-stars{color:var(--accent);font-size:22px;letter-spacing:2px} +.rev-count{color:var(--faint);font-size:14px} +.rev-list{display:flex;flex-direction:column;gap:16px;margin-bottom:32px} +.rev-item{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:18px 20px} +.rev-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:6px} +.rev-name{font-weight:700;color:var(--ink)} +.rev-stars-sm{color:var(--accent);letter-spacing:1px} +.rev-text{color:var(--text);margin:6px 0;line-height:1.6} +.rev-date{font-size:12px;color:var(--faint)} +.rev-form-card{background:var(--sunken);border:1px solid var(--border);border-radius:var(--radius);padding:26px;max-width:560px} +.rev-stars-input{display:flex;gap:4px;margin-bottom:16px} +.rev-star-btn{background:none;border:none;font-size:32px;line-height:1;color:var(--border-2);cursor:pointer;padding:0;transition:.1s} +.rev-star-btn.on{color:var(--accent)} +.rev-form-card .field label{font-weight:600;font-size:14px;color:var(--ink)} +.rev-form-card input,.rev-form-card textarea{padding:11px 13px;border:1px solid var(--border-2);border-radius:var(--radius-sm);font-size:15px;font-family:inherit;background:var(--surface)} +.rev-form-card input:focus,.rev-form-card textarea:focus{outline:none;border-color:var(--accent)} diff --git a/test/unit.mjs b/test/unit.mjs index 4fc1bc0..d508ae3 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -29,5 +29,47 @@ t('Sanitizer entfernt onerror=', () => assert.ok(!/onerror/i.test(sanitizeHtml(' t('Sanitizer neutralisiert javascript:', () => assert.ok(!/javascript:/i.test(sanitizeHtml('x')))); t('Sanitizer lässt normales Markup', () => assert.ok(//.test(sanitizeHtml('fett')))); +// --- v2.3 Feature-Module --- +// Suche: Treffer + leerer Query +{ + const id = store.createProduct({ name: 'Blaues Wollknäuel Merino', shortName: 'Wollknäuel', priceCents: 990, category: 'Garne', desc: 'Weiche Merinowolle in Blau', mwst: 19 }); + t('Suche findet Produkt nach Name (woll)', () => { const r = store.searchProducts('woll'); assert.ok(r.some(p => p.id === id)); }); + t('Suche case-insensitiv (MERINO)', () => assert.ok(store.searchProducts('MERINO').some(p => p.id === id))); + t('Suche findet nach Kategorie (garne)', () => assert.ok(store.searchProducts('garne').some(p => p.id === id))); + t('Suche leerer Query → keine Treffer', () => assert.strictEqual(store.searchProducts('').length, 0)); + t('Suche ohne Treffer → leeres Array', () => assert.strictEqual(store.searchProducts('xyzgibtsnicht123').length, 0)); +} + +// Bewertungen: Durchschnitt nur über freigegebene +{ + const slug = 'test-review-produkt'; + const r1 = store.addReview({ product_slug: slug, name: 'A', rating: 5, text: 'top' }); + const r2 = store.addReview({ product_slug: slug, name: 'B', rating: 3, text: 'ok' }); + store.addReview({ product_slug: slug, name: 'C', rating: 1, text: 'nicht freigegeben' }); + store.setReviewApproved(r1.id, 1); + store.setReviewApproved(r2.id, 1); + t('Review-Average = 4.0 (5+3)/2, nur freigegebene', () => { const s = store.reviewSummary(slug); assert.strictEqual(s.count, 2); assert.strictEqual(s.average, 4); }); + t('Review-Rating ausserhalb 1–5 → Fehler', () => assert.ok(!store.addReview({ product_slug: slug, name: 'D', rating: 9 }).ok)); + t('addReview ohne Rating → Fehler', () => assert.ok(!store.addReview({ product_slug: slug, rating: 0 }).ok)); +} + +// Kundenkonten: Registrierung + Passwort-Prüfung (getrennte Auth) +{ + const reg = store.registerCustomer({ name: 'Test Kunde', email: 'kunde@example.com', password: 'geheim123' }); + t('Kunde registrieren', () => assert.ok(reg.ok && reg.id)); + t('Kunde Login korrekt', () => assert.ok(store.verifyCustomer('kunde@example.com', 'geheim123'))); + t('Kunde Login falsches Passwort → null', () => assert.strictEqual(store.verifyCustomer('kunde@example.com', 'falsch'), null)); + t('Kurzes Passwort → Fehler', () => assert.ok(!store.registerCustomer({ email: 'x@y.de', password: '123' }).ok)); +} + +// Abandoned-Cart: erfassen, fällig, recovered +{ + const cap = store.captureAbandonedCart({ email: 'cart@example.com', items: [{ name: 'X', priceCents: 1000, qty: 2 }], total_cents: 2000 }); + t('Abandoned-Cart erfassen', () => assert.ok(cap.ok && cap.id)); + t('Abandoned-Cart Stats zählt offen', () => assert.ok(store.abandonedCartStats().open >= 1)); + t('recovered markieren per E-Mail', () => { const n = store.markCartRecoveredByEmail('cart@example.com'); assert.ok(n >= 1); }); + t('recovered Karte nicht mehr fällig', () => { const due = store.dueAbandonedCarts(0); assert.ok(!due.some(c => c.email === 'cart@example.com')); }); +} + console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0);