v2.3: Feature-Module live — Suche, Merkliste, Kundenkonten+Adressbuch, Bewertungen, Abandoned-Cart
- feature_search: Storefront-Header-Suche + /suche (SSR, SQLite LIKE, case-insensitiv; Name/Kurz/Desc/Material/Kategorie), Treffer als Karten, Leer-Zustand - feature_wishlist: Herz-Button auf Karten/PDP (localStorage, public/wishlist.js) + /merkliste - feature_accounts: getrennte Kunden-Session (Cookie hdc_customer, scrypt), /konto/registrieren|anmelden|abmelden, /konto (Bestellhistorie+Adressbuch), Tabelle customer_addresses, Checkout-Vorbefuellung + orders.customer_id-Zuordnung; Gast-Checkout bleibt - feature_reviews: Tabelle reviews (1-5, Moderation), /api/review (approved=0), PDP-Anzeige Durchschnitt+Reviews + aggregateRating-JSON-LD, Admin /bewertungen (Freigeben/Verbergen/Loeschen) + Nav-Zaehler - feature_abandoned_cart: Tabelle abandoned_carts, /api/cart-capture beim Checkout-Start, /api/cron/abandoned (CRON_TOKEN) sendet Erinnerungsmail (Mailer/Log) + reminded=1, recovered=1 bei Bestellung; Status in Einstellungen - Gating: Flag aus => Storefront-Elemente weg, Routen 302/404, Admin-Nav-Punkt entfaellt; KEIN 'in Vorbereitung' mehr - API/MCP: reviews CRUD + abandoned_carts (read) in admin-api + ai-admin.txt + MCP-Tools; Manifest v2.3 - README + .env.example (CRON_TOKEN, ABANDONED_AFTER_MINUTES); 16 neue Unit-Tests (Suche/Review-Avg/Kunden/Abandoned)
This commit is contained in:
+21
-2
@@ -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 <HDC_API_TOKEN>',
|
||||
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).',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 => `
|
||||
<tr>
|
||||
<td style="padding:8px 0;border-bottom:1px solid #eee">${esc(i.name)}${i.size && i.size !== 'One Size' ? ' <span style="color:#888">(' + esc(i.size) + ')</span>' : ''}</td>
|
||||
<td style="padding:8px 0;border-bottom:1px solid #eee;text-align:center;color:#555">${esc(i.qty || 1)}×</td>
|
||||
<td style="padding:8px 0;border-bottom:1px solid #eee;text-align:right;white-space:nowrap">${esc(formatPrice((Number(i.priceCents) || 0) * (Number(i.qty) || 1)))}</td>
|
||||
</tr>`).join('');
|
||||
return `<!doctype html><html lang="de"><body style="margin:0;background:#f6f4f1;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#222">
|
||||
<div style="max-width:560px;margin:0 auto;padding:24px">
|
||||
<div style="background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.06)">
|
||||
<div style="background:${esc(accent)};padding:22px 28px;color:#fff">
|
||||
<div style="font-size:20px;font-weight:700">${esc(shopName)}</div>
|
||||
<div style="opacity:.9;font-size:14px;margin-top:2px">Du hast etwas vergessen</div>
|
||||
</div>
|
||||
<div style="padding:28px">
|
||||
<p style="margin:0 0 14px;font-size:16px">Dein Warenkorb wartet noch auf dich. Diese Artikel hast du dir angesehen:</p>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:14px"><tbody>${rows}</tbody></table>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:14px;margin-top:10px">
|
||||
<tr><td style="padding:10px 0 0;font-weight:700;font-size:16px;border-top:1px solid #eee">Summe</td><td style="padding:10px 0 0;text-align:right;font-weight:700;font-size:16px;border-top:1px solid #eee">${esc(formatPrice(Number(cart.total_cents) || 0))}</td></tr>
|
||||
</table>
|
||||
<div style="margin-top:24px"><a href="${esc(shopUrl)}" style="display:inline-block;background:${esc(accent)};color:#fff;text-decoration:none;padding:13px 26px;border-radius:10px;font-weight:600">Warenkorb ansehen</a></div>
|
||||
<p style="margin:22px 0 0;font-size:13px;color:#888">Falls du die Bestellung schon abgeschlossen hast, kannst du diese E-Mail ignorieren.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:center;color:#aaa;font-size:12px;margin-top:14px">© ${new Date().getFullYear()} ${esc(shopName)} · powered by hd-commerce</div>
|
||||
</div></body></html>`;
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user