363 lines
22 KiB
JavaScript
363 lines
22 KiB
JavaScript
import Database from 'better-sqlite3';
|
|
import { mkdirSync } from 'node:fs';
|
|
import { dirname } from 'node:path';
|
|
import {
|
|
SEED_SETTINGS, SEED_PRODUCTS, SEED_CUSTOMERS, seedOrders,
|
|
SEED_SLIDES, SEED_PAGES, SEED_POPUPS,
|
|
} from './seed.js';
|
|
|
|
const DB_PATH = process.env.DB_PATH || './data/hdc.db';
|
|
try { mkdirSync(dirname(DB_PATH), { recursive: true }); } catch {}
|
|
const db = new Database(DB_PATH);
|
|
db.pragma('journal_mode = WAL');
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY, value TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS products (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
slug TEXT UNIQUE NOT NULL, name TEXT NOT NULL, shortName TEXT,
|
|
priceCents INTEGER NOT NULL DEFAULT 0, category TEXT,
|
|
sizes TEXT DEFAULT '["One Size"]', images TEXT DEFAULT '[]', cardImage TEXT,
|
|
badge TEXT DEFAULT '', stock INTEGER, material TEXT DEFAULT '', features TEXT DEFAULT '[]',
|
|
featured INTEGER DEFAULT 0, sort INTEGER DEFAULT 99, desc TEXT DEFAULT '', metafields TEXT DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS orders (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, number TEXT UNIQUE, email TEXT, customer_name TEXT,
|
|
status TEXT DEFAULT 'pending', total_cents INTEGER DEFAULT 0, items TEXT DEFAULT '[]',
|
|
address TEXT DEFAULT '', created_at TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS customers (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT UNIQUE, city TEXT, created_at TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS slides (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, image TEXT, headline TEXT, subline TEXT, link TEXT,
|
|
sort INTEGER DEFAULT 99, active INTEGER DEFAULT 1, created_at TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS pages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, title TEXT, body TEXT,
|
|
type TEXT DEFAULT 'content', active INTEGER DEFAULT 1, sort INTEGER DEFAULT 99
|
|
);
|
|
CREATE TABLE IF NOT EXISTS popups (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, type TEXT DEFAULT 'newsletter',
|
|
headline TEXT, body TEXT, image TEXT, cta_text TEXT, cta_url TEXT,
|
|
trigger TEXT DEFAULT 'delay', trigger_value INTEGER DEFAULT 3, target_path TEXT DEFAULT '/',
|
|
freq TEXT DEFAULT 'session', active INTEGER DEFAULT 1, sort INTEGER DEFAULT 99, created_at TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS subscribers (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE, source TEXT, created_at TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, path TEXT, referrer TEXT,
|
|
utm_source TEXT, utm_medium TEXT, utm_campaign TEXT, session TEXT,
|
|
value_cents INTEGER DEFAULT 0, meta TEXT DEFAULT '{}', created_at TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS media (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT, url TEXT, mime TEXT, size INTEGER, 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);
|
|
`);
|
|
|
|
// ---------- 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 });
|
|
const O = (r) => r && ({ ...r, items: JSON.parse(r.items || '[]') });
|
|
const E = (r) => r && ({ ...r, meta: JSON.parse(r.meta || '{}') });
|
|
|
|
// ---------- seed ----------
|
|
function seedIfEmpty() {
|
|
if (db.prepare('SELECT COUNT(*) c FROM settings').get().c === 0) {
|
|
const ins = db.prepare('INSERT OR REPLACE INTO settings (key,value) VALUES (?,?)');
|
|
for (const [k, v] of Object.entries(SEED_SETTINGS)) ins.run(k, String(v));
|
|
}
|
|
if (db.prepare('SELECT COUNT(*) c FROM products').get().c === 0) {
|
|
const ins = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,sizes,images,cardImage,badge,stock,material,features,featured,sort,desc,metafields)
|
|
VALUES (@slug,@name,@shortName,@priceCents,@category,@sizes,@images,@cardImage,@badge,@stock,@material,@features,@featured,@sort,@desc,@metafields)`);
|
|
const tx = db.transaction(rows => rows.forEach(p => ins.run({ ...p, sizes: JSON.stringify(p.sizes), images: JSON.stringify(p.images), features: JSON.stringify(p.features), featured: p.featured ? 1 : 0, metafields: JSON.stringify(p.metafields || {}) })));
|
|
tx(SEED_PRODUCTS);
|
|
}
|
|
if (db.prepare('SELECT COUNT(*) c FROM customers').get().c === 0) {
|
|
const ic = db.prepare('INSERT OR IGNORE INTO customers (name,email,city,created_at) VALUES (@name,@email,@city,@created_at)');
|
|
const now = new Date().toISOString();
|
|
SEED_CUSTOMERS.forEach(c => ic.run({ ...c, created_at: now }));
|
|
}
|
|
if (db.prepare('SELECT COUNT(*) c FROM orders').get().c === 0) {
|
|
const io = db.prepare('INSERT INTO orders (number,email,customer_name,status,total_cents,items,address,created_at) VALUES (@number,@email,@customer_name,@status,@total_cents,@items,@address,@created_at)');
|
|
seedOrders().forEach(o => io.run({ ...o, items: JSON.stringify(o.items) }));
|
|
}
|
|
if (db.prepare('SELECT COUNT(*) c FROM slides').get().c === 0) {
|
|
const is = db.prepare('INSERT INTO slides (image,headline,subline,link,sort,active,created_at) VALUES (@image,@headline,@subline,@link,@sort,@active,@created_at)');
|
|
const now = new Date().toISOString();
|
|
SEED_SLIDES.forEach(s => is.run({ ...s, created_at: now }));
|
|
}
|
|
if (db.prepare('SELECT COUNT(*) c FROM pages').get().c === 0) {
|
|
const ip = db.prepare('INSERT INTO pages (slug,title,body,type,active,sort) VALUES (@slug,@title,@body,@type,@active,@sort)');
|
|
SEED_PAGES.forEach(p => ip.run(p));
|
|
}
|
|
if (db.prepare('SELECT COUNT(*) c FROM popups').get().c === 0) {
|
|
const ip = 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 (@title,@type,@headline,@body,@image,@cta_text,@cta_url,@trigger,@trigger_value,@target_path,@freq,@active,@sort,@created_at)');
|
|
const now = new Date().toISOString();
|
|
SEED_POPUPS.forEach(p => ip.run({ ...p, created_at: now }));
|
|
}
|
|
// seed some demo analytics events so the analytics dashboard is not empty
|
|
if (db.prepare('SELECT COUNT(*) c FROM events').get().c === 0) seedEvents();
|
|
}
|
|
|
|
function seedEvents() {
|
|
const sources = [
|
|
{ utm_source: 'google', utm_medium: 'organic' },
|
|
{ utm_source: 'instagram', utm_medium: 'social' },
|
|
{ utm_source: 'newsletter', utm_medium: 'email' },
|
|
{ utm_source: 'direct', utm_medium: 'none' },
|
|
{ utm_source: 'pinterest', utm_medium: 'social' },
|
|
];
|
|
const slugs = SEED_PRODUCTS.map(p => p.slug);
|
|
const ins = db.prepare(`INSERT INTO events (type,path,referrer,utm_source,utm_medium,utm_campaign,session,value_cents,meta,created_at)
|
|
VALUES (@type,@path,@referrer,@utm_source,@utm_medium,@utm_campaign,@session,@value_cents,@meta,@created_at)`);
|
|
const tx = db.transaction(() => {
|
|
for (let d = 29; d >= 0; d--) {
|
|
const base = new Date(); base.setDate(base.getDate() - d);
|
|
const visits = 30 + Math.floor(Math.random() * 50);
|
|
for (let v = 0; v < visits; v++) {
|
|
const src = sources[Math.floor(Math.random() * sources.length)];
|
|
const session = 's' + d + '_' + v;
|
|
const ts = new Date(base); ts.setHours(8 + Math.floor(Math.random() * 12), Math.floor(Math.random() * 60));
|
|
const at = ts.toISOString();
|
|
const row = { referrer: src.utm_source, utm_source: src.utm_source, utm_medium: src.utm_medium, utm_campaign: '', session, value_cents: 0, meta: '{}', created_at: at };
|
|
ins.run({ ...row, type: 'pageview', path: '/' });
|
|
if (Math.random() < 0.55) {
|
|
const slug = slugs[Math.floor(Math.random() * slugs.length)];
|
|
ins.run({ ...row, type: 'pageview', path: '/produkt/' + slug });
|
|
ins.run({ ...row, type: 'product_view', path: '/produkt/' + slug, meta: JSON.stringify({ slug }) });
|
|
if (Math.random() < 0.35) {
|
|
ins.run({ ...row, type: 'add_to_cart', path: '/produkt/' + slug, meta: JSON.stringify({ slug }) });
|
|
if (Math.random() < 0.4) {
|
|
ins.run({ ...row, type: 'checkout_start', path: '/checkout' });
|
|
if (Math.random() < 0.6) {
|
|
const val = 1500 + Math.floor(Math.random() * 4000);
|
|
ins.run({ ...row, type: 'purchase', path: '/bestellung-erfolgreich', value_cents: val, meta: JSON.stringify({ slug }) });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
tx();
|
|
}
|
|
seedIfEmpty();
|
|
|
|
// ---------- settings ----------
|
|
export function getSettings() {
|
|
const rows = db.prepare('SELECT key,value FROM settings').all();
|
|
const o = {};
|
|
for (const r of rows) o[r.key] = r.value;
|
|
return o;
|
|
}
|
|
export const getSetting = (k, fallback = '') => { const r = db.prepare('SELECT value FROM settings WHERE key=?').get(k); return r ? r.value : fallback; };
|
|
export const setSetting = (k, v) => db.prepare('INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(k, String(v ?? ''));
|
|
export function formatPrice(cents) {
|
|
const cur = getSetting('currency', 'EUR');
|
|
const n = (Number(cents) || 0) / 100;
|
|
try { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: cur }).format(n); }
|
|
catch { return n.toFixed(2) + ' ' + cur; }
|
|
}
|
|
|
|
// ---------- products ----------
|
|
export const listProducts = () => db.prepare('SELECT * FROM products ORDER BY sort, id').all().map(P);
|
|
export const listFeatured = () => db.prepare('SELECT * FROM products WHERE featured=1 ORDER BY sort, id').all().map(P);
|
|
export const getProductBySlug = (slug) => P(db.prepare('SELECT * FROM products WHERE slug=?').get(slug));
|
|
export const getProductById = (id) => P(db.prepare('SELECT * FROM products WHERE id=?').get(Number(id)));
|
|
export const listCategories = () => [...new Set(db.prepare("SELECT category FROM products WHERE category IS NOT NULL AND category<>'' ORDER BY sort").all().map(r => r.category))];
|
|
function normProduct(d) {
|
|
const cardImage = d.cardImage || (Array.isArray(d.images) && d.images[0]) || '';
|
|
return {
|
|
slug: d.slug, name: d.name, shortName: d.shortName || d.name, priceCents: Math.round(Number(d.priceCents) || 0), category: d.category || '',
|
|
sizes: JSON.stringify(d.sizes && d.sizes.length ? d.sizes : ['One Size']), images: JSON.stringify(d.images || []), cardImage,
|
|
badge: d.badge || '', stock: (d.stock === '' || d.stock == null) ? null : Math.round(Number(d.stock)), material: d.material || '',
|
|
features: JSON.stringify(d.features || []), featured: d.featured ? 1 : 0, sort: Number(d.sort) || 99, desc: d.desc || '',
|
|
metafields: JSON.stringify(d.metafields || {}),
|
|
};
|
|
}
|
|
export function createProduct(d) {
|
|
const r = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,sizes,images,cardImage,badge,stock,material,features,featured,sort,desc,metafields)
|
|
VALUES (@slug,@name,@shortName,@priceCents,@category,@sizes,@images,@cardImage,@badge,@stock,@material,@features,@featured,@sort,@desc,@metafields)`).run(normProduct(d));
|
|
return r.lastInsertRowid;
|
|
}
|
|
export function updateProduct(id, d) {
|
|
db.prepare(`UPDATE products SET slug=@slug,name=@name,shortName=@shortName,priceCents=@priceCents,category=@category,sizes=@sizes,images=@images,cardImage=@cardImage,badge=@badge,stock=@stock,material=@material,features=@features,featured=@featured,sort=@sort,desc=@desc,metafields=@metafields WHERE id=@id`)
|
|
.run({ ...normProduct(d), id: Number(id) });
|
|
return id;
|
|
}
|
|
export const deleteProduct = (id) => db.prepare('DELETE FROM products WHERE id=?').run(Number(id));
|
|
|
|
// ---------- orders ----------
|
|
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 = '' }) {
|
|
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);
|
|
if (email) {
|
|
db.prepare('INSERT OR IGNORE INTO customers (name,email,city,created_at) VALUES (?,?,?,?)').run(customer_name || '', email, '', now);
|
|
}
|
|
recordEvent({ type: 'purchase', path: '/bestellung-erfolgreich', value_cents: total_cents || 0, meta: { number } });
|
|
return { id: r.lastInsertRowid, number };
|
|
}
|
|
export const updateOrderStatus = (id, status) => db.prepare('UPDATE orders SET status=? WHERE id=?').run(status, Number(id));
|
|
|
|
// ---------- customers ----------
|
|
export function listCustomers() {
|
|
const rows = db.prepare('SELECT * FROM customers ORDER BY id').all();
|
|
return rows.map(c => {
|
|
const agg = db.prepare("SELECT COUNT(*) cnt, COALESCE(SUM(total_cents),0) spent FROM orders WHERE email=? AND status NOT IN ('cancelled','refunded')").get(c.email);
|
|
return { ...c, orders_count: agg.cnt, total_spent_cents: agg.spent };
|
|
});
|
|
}
|
|
export const getCustomerById = (id) => db.prepare('SELECT * FROM customers WHERE id=?').get(Number(id));
|
|
|
|
// ---------- slides ----------
|
|
export const listSlides = () => db.prepare('SELECT * FROM slides ORDER BY sort, id').all();
|
|
export const listActiveSlides = () => db.prepare('SELECT * FROM slides WHERE active=1 ORDER BY sort, id').all();
|
|
export const getSlideById = (id) => db.prepare('SELECT * FROM slides WHERE id=?').get(Number(id));
|
|
export function createSlide(d) {
|
|
return db.prepare('INSERT INTO slides (image,headline,subline,link,sort,active,created_at) VALUES (?,?,?,?,?,?,?)')
|
|
.run(d.image || '', d.headline || '', d.subline || '', d.link || '', Number(d.sort) || 99, d.active ? 1 : 0, new Date().toISOString()).lastInsertRowid;
|
|
}
|
|
export function updateSlide(id, d) {
|
|
db.prepare('UPDATE slides SET image=?,headline=?,subline=?,link=?,sort=?,active=? WHERE id=?')
|
|
.run(d.image || '', d.headline || '', d.subline || '', d.link || '', Number(d.sort) || 99, d.active ? 1 : 0, Number(id));
|
|
return id;
|
|
}
|
|
export const deleteSlide = (id) => db.prepare('DELETE FROM slides WHERE id=?').run(Number(id));
|
|
|
|
// ---------- pages ----------
|
|
export const listPages = () => db.prepare('SELECT * FROM pages ORDER BY sort, id').all();
|
|
export const listActivePages = () => db.prepare('SELECT * FROM pages WHERE active=1 ORDER BY sort, id').all();
|
|
export const listLegalPages = () => db.prepare("SELECT * FROM pages WHERE active=1 AND type='legal' ORDER BY sort, id").all();
|
|
export const getPageBySlug = (slug) => db.prepare('SELECT * FROM pages WHERE slug=?').get(slug);
|
|
export const getPageById = (id) => db.prepare('SELECT * FROM pages WHERE id=?').get(Number(id));
|
|
export function createPage(d) {
|
|
return db.prepare('INSERT INTO pages (slug,title,body,type,active,sort) VALUES (?,?,?,?,?,?)')
|
|
.run(d.slug, d.title || '', d.body || '', d.type || 'content', d.active ? 1 : 0, Number(d.sort) || 99).lastInsertRowid;
|
|
}
|
|
export function updatePage(id, d) {
|
|
db.prepare('UPDATE pages SET slug=?,title=?,body=?,type=?,active=?,sort=? WHERE id=?')
|
|
.run(d.slug, d.title || '', d.body || '', d.type || 'content', d.active ? 1 : 0, Number(d.sort) || 99, Number(id));
|
|
return id;
|
|
}
|
|
export const deletePage = (id) => db.prepare('DELETE FROM pages WHERE id=?').run(Number(id));
|
|
|
|
// ---------- popups ----------
|
|
export const listPopups = () => db.prepare('SELECT * FROM popups ORDER BY sort, id').all();
|
|
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))));
|
|
}
|
|
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
.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;
|
|
}
|
|
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=?`)
|
|
.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));
|
|
return id;
|
|
}
|
|
export const deletePopup = (id) => db.prepare('DELETE FROM popups WHERE id=?').run(Number(id));
|
|
|
|
// ---------- subscribers ----------
|
|
export function addSubscriber(email, source = 'web') {
|
|
if (!email) return { ok: false };
|
|
try { db.prepare('INSERT INTO subscribers (email,source,created_at) VALUES (?,?,?)').run(email, source, new Date().toISOString()); return { ok: true }; }
|
|
catch { return { ok: true, dup: true }; }
|
|
}
|
|
export const listSubscribers = () => db.prepare('SELECT * FROM subscribers ORDER BY id DESC').all();
|
|
|
|
// ---------- media ----------
|
|
export function addMedia(d) {
|
|
return db.prepare('INSERT INTO media (filename,url,mime,size,created_at) VALUES (?,?,?,?,?)')
|
|
.run(d.filename, d.url, d.mime || '', d.size || 0, new Date().toISOString()).lastInsertRowid;
|
|
}
|
|
export const listMedia = () => db.prepare('SELECT * FROM media ORDER BY id DESC').all();
|
|
|
|
// ---------- events / analytics ----------
|
|
export function recordEvent({ type, path = '', referrer = '', utm_source = '', utm_medium = '', utm_campaign = '', session = '', value_cents = 0, meta = {} }) {
|
|
try {
|
|
db.prepare(`INSERT INTO events (type,path,referrer,utm_source,utm_medium,utm_campaign,session,value_cents,meta,created_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?)`).run(type, path, referrer, utm_source, utm_medium, utm_campaign, session, Number(value_cents) || 0, JSON.stringify(meta || {}), new Date().toISOString());
|
|
} catch {}
|
|
}
|
|
|
|
export function analyticsSummary(days = 30) {
|
|
const since = new Date(); since.setDate(since.getDate() - days);
|
|
const s = since.toISOString();
|
|
const cnt = (type) => db.prepare('SELECT COUNT(*) c FROM events WHERE type=? AND created_at>=?').get(type, s).c;
|
|
const pageviews = cnt('pageview');
|
|
const productViews = cnt('product_view');
|
|
const addToCart = cnt('add_to_cart');
|
|
const checkoutStart = cnt('checkout_start');
|
|
const purchases = cnt('purchase');
|
|
const visitors = db.prepare("SELECT COUNT(DISTINCT session) c FROM events WHERE type='pageview' AND created_at>=?").get(s).c;
|
|
const revenue = db.prepare("SELECT COALESCE(SUM(value_cents),0) v FROM events WHERE type='purchase' AND created_at>=?").get(s).v;
|
|
const conversion = visitors ? (purchases / visitors) * 100 : 0;
|
|
const aov = purchases ? revenue / purchases : 0;
|
|
|
|
const bySource = db.prepare(`SELECT COALESCE(NULLIF(utm_source,''),'direct') src,
|
|
COUNT(DISTINCT session) visitors,
|
|
SUM(CASE WHEN type='purchase' THEN 1 ELSE 0 END) purchases,
|
|
COALESCE(SUM(value_cents),0) revenue
|
|
FROM events WHERE created_at>=? GROUP BY src ORDER BY revenue DESC, visitors DESC`).all(s);
|
|
|
|
const topProducts = db.prepare(`SELECT json_extract(meta,'$.slug') slug,
|
|
SUM(CASE WHEN type='product_view' THEN 1 ELSE 0 END) views,
|
|
SUM(CASE WHEN type='purchase' THEN 1 ELSE 0 END) buys
|
|
FROM events WHERE created_at>=? AND json_extract(meta,'$.slug') IS NOT NULL
|
|
GROUP BY slug ORDER BY views DESC LIMIT 8`).all(s).map(r => {
|
|
const p = getProductBySlug(r.slug);
|
|
return { slug: r.slug, name: p ? p.shortName || p.name : r.slug, views: r.views, buys: r.buys, rate: r.views ? (r.buys / r.views) * 100 : 0 };
|
|
});
|
|
|
|
const series = [];
|
|
for (let i = days - 1; i >= 0; i--) {
|
|
const d0 = new Date(); d0.setDate(d0.getDate() - i); d0.setHours(0, 0, 0, 0);
|
|
const d1 = new Date(d0); d1.setDate(d1.getDate() + 1);
|
|
const a = d0.toISOString(), b = d1.toISOString();
|
|
const views = db.prepare("SELECT COUNT(*) c FROM events WHERE type='pageview' AND created_at>=? AND created_at<?").get(a, b).c;
|
|
const rev = db.prepare("SELECT COALESCE(SUM(value_cents),0) v FROM events WHERE type='purchase' AND created_at>=? AND created_at<?").get(a, b).v;
|
|
series.push({ date: d0.toISOString().slice(0, 10), label: d0.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }), views, revenue: rev });
|
|
}
|
|
|
|
return {
|
|
days, visitors, pageviews, productViews, addToCart, checkoutStart, purchases, revenue, conversion, aov,
|
|
funnel: [
|
|
{ label: 'Aufrufe', value: pageviews },
|
|
{ label: 'Produktansichten', value: productViews },
|
|
{ label: 'In den Korb', value: addToCart },
|
|
{ label: 'Checkout', value: checkoutStart },
|
|
{ label: 'Kauf', value: purchases },
|
|
],
|
|
bySource, topProducts, series,
|
|
};
|
|
}
|
|
|
|
// ---------- dashboard ----------
|
|
export function dashboard() {
|
|
const revenue = db.prepare("SELECT COALESCE(SUM(total_cents),0) s FROM orders WHERE status NOT IN ('cancelled','refunded')").get().s;
|
|
const orderCount = db.prepare('SELECT COUNT(*) c FROM orders').get().c;
|
|
const productCount = db.prepare('SELECT COUNT(*) c FROM products').get().c;
|
|
const customerCount = db.prepare('SELECT COUNT(*) c FROM customers').get().c;
|
|
const pending = db.prepare("SELECT COUNT(*) c FROM orders WHERE status='pending'").get().c;
|
|
const recentOrders = db.prepare('SELECT * FROM orders ORDER BY datetime(created_at) DESC, id DESC LIMIT 6').all().map(O);
|
|
const lowStock = db.prepare('SELECT * FROM products WHERE stock IS NOT NULL AND stock <= 35 ORDER BY stock ASC LIMIT 6').all().map(P);
|
|
const a = analyticsSummary(30);
|
|
return { revenueCents: revenue, orderCount, productCount, customerCount, pending, recentOrders, lowStock, funnelMini: { views: a.pageviews, cart: a.addToCart, buy: a.purchases } };
|
|
}
|