hd-commerce: neutrales SQLite-Commerce-Backend (Admin + API + Demo-Storefront)

This commit is contained in:
2026-06-17 12:05:29 +00:00
commit 4e8a3ab105
43 changed files with 2689 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
// Demo-Seed "Brittas Naehkiste" — neutrale Kurzwaren/Naehbedarf-Daten.
// Diese Demo-Instanz ist nur ein Beispiel; Brand/Inhalte sind ueber das Admin anpassbar.
const img = (slug) => `https://picsum.photos/seed/${slug}/900/1100`;
export const SEED_SETTINGS = {
shop_name: 'Brittas Nähkiste',
brand_accent: '#b8566a',
brand_accent_dark: '#8d3f50',
currency: 'EUR',
free_shipping_cents: '4900',
announcement_text: 'Versandkostenfrei ab 49 €',
announcement_active: '1',
announcement_link: '/shop',
shop_tagline: 'Stoffe, Garne & alles fürs Nähen',
shop_email: 'hallo@brittas-naehkiste.de',
};
export const SEED_PRODUCTS = [
{
slug: 'baumwollstoff-bluetenwiese', name: 'Baumwollstoff „Blütenwiese" (Meterware)', shortName: 'Baumwollstoff Blütenwiese',
priceCents: 1290, category: 'Stoffe', sizes: ['0,5 m', '1 m', '2 m', '3 m'],
images: [img('bluetenwiese'), img('bluetenwiese2')], cardImage: img('bluetenwiese'),
badge: 'Beliebt', stock: 120, material: '100 % Baumwolle, 145 cm breit',
features: ['Öko-Tex Standard 100', 'Pflegeleicht, 40°C waschbar', 'Ideal für Kleider & Deko'],
featured: true, sort: 1, desc: 'Leichter Baumwollstoff mit zartem Blütenmuster — perfekt für sommerliche Kleider, Blusen und Heimtextilien. Preis pro laufendem Meter.'
},
{
slug: 'jersey-marine', name: 'Jersey „Marine" (Meterware)', shortName: 'Jersey Marine',
priceCents: 1590, category: 'Stoffe', sizes: ['0,5 m', '1 m', '2 m'],
images: [img('jerseymarine'), img('jerseymarine2')], cardImage: img('jerseymarine'),
badge: '', stock: 80, material: '95 % Baumwolle, 5 % Elasthan, 160 cm breit',
features: ['Elastisch & formstabil', 'Angenehm weicher Griff', 'Für Shirts & Kinderkleidung'],
featured: true, sort: 2, desc: 'Weicher, dehnbarer Baumwoll-Jersey in tiefem Marineblau. Vielseitig für Shirts, Hoodies und Babykleidung. Preis pro Meter.'
},
{
slug: 'naehgarn-set-12-farben', name: 'Nähgarn-Set 12 Farben', shortName: 'Nähgarn-Set 12',
priceCents: 990, category: 'Garne & Fäden', sizes: ['One Size'],
images: [img('naehgarn'), img('naehgarn2')], cardImage: img('naehgarn'),
badge: 'Set', stock: 60, material: '100 % Polyester, je 200 m',
features: ['12 aufeinander abgestimmte Farben', 'Reißfest & universell', 'Für Hand- und Maschinennähen'],
featured: true, sort: 3, desc: 'Praktisches Allround-Set mit zwölf Nähgarnen in harmonischen Farbtönen. Geeignet für nahezu alle Stoffe und Projekte.'
},
{
slug: 'knopf-sortiment-50', name: 'Knopf-Sortiment 50 Stück', shortName: 'Knopf-Sortiment 50',
priceCents: 690, category: 'Kurzwaren', sizes: ['One Size'],
images: [img('knoepfe'), img('knoepfe2')], cardImage: img('knoepfe'),
badge: '', stock: 45, material: 'Kunststoff & Holz, gemischt',
features: ['Verschiedene Größen & Farben', '2- und 4-Loch-Knöpfe', 'Praktische Sortierbox'],
featured: false, sort: 4, desc: 'Bunt gemischtes Knopf-Sortiment mit 50 Stück in unterschiedlichen Größen, Farben und Materialien — für jedes Nähprojekt etwas dabei.'
},
{
slug: 'reissverschluss-set', name: 'Reißverschluss-Set (10 Stück)', shortName: 'Reißverschluss-Set',
priceCents: 1190, category: 'Kurzwaren', sizes: ['20 cm', '30 cm', '40 cm'],
images: [img('reissverschluss'), img('reissverschluss2')], cardImage: img('reissverschluss'),
badge: '', stock: 70, material: 'Kunststoffspirale, Metallschieber',
features: ['10 Stück gemischte Farben', 'Teilbar & nicht teilbar', 'Robuster Schieber'],
featured: false, sort: 5, desc: 'Sortiment aus zehn Reißverschlüssen in verschiedenen Farben und Längen — passend für Taschen, Jacken und Kissenbezüge.'
},
{
slug: 'schnittmuster-sommerkleid-lotta', name: 'Schnittmuster „Sommerkleid Lotta"', shortName: 'Schnittmuster Lotta',
priceCents: 890, category: 'Schnittmuster', sizes: ['Gr. 3448'],
images: [img('schnittmuster'), img('schnittmuster2')], cardImage: img('schnittmuster'),
badge: 'Neu', stock: 999, material: 'Papier-Schnittmuster + Anleitung',
features: ['Größen 34 bis 48', 'Bebilderte Schritt-für-Schritt-Anleitung', 'Anfängerfreundlich'],
featured: true, sort: 6, desc: 'Leichtes A-Linien-Sommerkleid mit Taschen und Knopfleiste. Inklusive ausführlicher Nähanleitung in mehreren Größen.'
},
{
slug: 'stoffschere-profi', name: 'Stoffschere Profi 24 cm', shortName: 'Stoffschere Profi',
priceCents: 2490, category: 'Werkzeug', sizes: ['One Size'],
images: [img('stoffschere'), img('stoffschere2')], cardImage: img('stoffschere'),
badge: 'Profi', stock: 30, material: 'Rostfreier Edelstahl',
features: ['Geschliffene Präzisionsklinge', 'Ergonomischer Griff', 'Für Stoff, Leder & Filz'],
featured: false, sort: 7, desc: 'Hochwertige Schneiderschere mit langer Edelstahlklinge für saubere, mühelose Schnitte durch dicke und dünne Stoffe.'
},
{
slug: 'massband-stecknadel-set', name: 'Maßband + Stecknadel-Set', shortName: 'Maßband & Nadeln',
priceCents: 590, category: 'Werkzeug', sizes: ['One Size'],
images: [img('massband'), img('massband2')], cardImage: img('massband'),
badge: '', stock: 90, material: 'Maßband 150 cm, 100 Stecknadeln',
features: ['Flexibles Rollmaßband', '100 Stecknadeln mit Glaskopf', 'Inkl. Nadelkissen'],
featured: false, sort: 8, desc: 'Praktisches Starter-Set mit flexiblem Maßband und 100 Glaskopf-Stecknadeln inklusive Nadelkissen — die Basis jeder Nähausstattung.'
},
];
export const SEED_CUSTOMERS = [
{ name: 'Anja Vogel', email: 'anja.vogel@example.de', city: 'Hamburg' },
{ name: 'Beate Lindner', email: 'beate.lindner@example.de', city: 'Bremen' },
{ name: 'Carla Sommer', email: 'carla.sommer@example.de', city: 'Hannover' },
{ name: 'Doreen Krause', email: 'doreen.krause@example.de', city: 'Lüneburg' },
{ name: 'Elena Fischer', email: 'elena.fischer@example.de', city: 'Buxtehude' },
];
export function seedOrders() {
const day = (n) => { const d = new Date(); d.setDate(d.getDate() - n); return d.toISOString(); };
return [
{ number: 'BNK-1001', email: 'anja.vogel@example.de', customer_name: 'Anja Vogel', status: 'fulfilled', total_cents: 3370,
items: [{ name: 'Baumwollstoff „Blütenwiese" (Meterware)', size: '2 m', qty: 2, priceCents: 1290 }, { name: 'Nähgarn-Set 12 Farben', size: 'One Size', qty: 1, priceCents: 990 }],
address: 'Anja Vogel, Lindenweg 4, 20095 Hamburg', created_at: day(18) },
{ number: 'BNK-1002', email: 'beate.lindner@example.de', customer_name: 'Beate Lindner', status: 'fulfilled', total_cents: 2480,
items: [{ name: 'Stoffschere Profi 24 cm', size: 'One Size', qty: 1, priceCents: 2490 }],
address: 'Beate Lindner, Marktstraße 11, 28199 Bremen', created_at: day(14) },
{ number: 'BNK-1003', email: 'carla.sommer@example.de', customer_name: 'Carla Sommer', status: 'pending', total_cents: 4170,
items: [{ name: 'Jersey „Marine" (Meterware)', size: '2 m', qty: 2, priceCents: 1590 }, { name: 'Knopf-Sortiment 50 Stück', size: 'One Size', qty: 1, priceCents: 690 }],
address: 'Carla Sommer, Am Bach 7, 30159 Hannover', created_at: day(9) },
{ number: 'BNK-1004', email: 'doreen.krause@example.de', customer_name: 'Doreen Krause', status: 'pending', total_cents: 1780,
items: [{ name: 'Schnittmuster „Sommerkleid Lotta"', size: 'Gr. 3448', qty: 2, priceCents: 890 }],
address: 'Doreen Krause, Gartenstraße 22, 21335 Lüneburg', created_at: day(6) },
{ number: 'BNK-1005', email: 'elena.fischer@example.de', customer_name: 'Elena Fischer', status: 'fulfilled', total_cents: 2670,
items: [{ name: 'Reißverschluss-Set (10 Stück)', size: '30 cm', qty: 1, priceCents: 1190 }, { name: 'Maßband + Stecknadel-Set', size: 'One Size', qty: 1, priceCents: 590 }, { name: 'Knopf-Sortiment 50 Stück', size: 'One Size', qty: 1, priceCents: 690 }],
address: 'Elena Fischer, Hauptstraße 3, 21614 Buxtehude', created_at: day(5) },
{ number: 'BNK-1006', email: 'anja.vogel@example.de', customer_name: 'Anja Vogel', status: 'cancelled', total_cents: 1290,
items: [{ name: 'Baumwollstoff „Blütenwiese" (Meterware)', size: '1 m', qty: 1, priceCents: 1290 }],
address: 'Anja Vogel, Lindenweg 4, 20095 Hamburg', created_at: day(4) },
{ number: 'BNK-1007', email: 'carla.sommer@example.de', customer_name: 'Carla Sommer', status: 'refunded', total_cents: 990,
items: [{ name: 'Nähgarn-Set 12 Farben', size: 'One Size', qty: 1, priceCents: 990 }],
address: 'Carla Sommer, Am Bach 7, 30159 Hannover', created_at: day(3) },
{ number: 'BNK-1008', email: 'beate.lindner@example.de', customer_name: 'Beate Lindner', status: 'pending', total_cents: 3180,
items: [{ name: 'Jersey „Marine" (Meterware)', size: '1 m', qty: 2, priceCents: 1590 }],
address: 'Beate Lindner, Marktstraße 11, 28199 Bremen', created_at: day(1) },
];
}
export const SEED_SLIDES = [
{ image: img('slide-neuheiten'), headline: 'Frische Stoffe für den Sommer', subline: 'Neue Baumwoll- und Jersey-Qualitäten — jetzt entdecken.', link: '/shop', sort: 1, active: 1 },
{ image: img('slide-aktion'), headline: 'Versandkostenfrei ab 49 €', subline: 'Jetzt das Nähkörbchen auffüllen und sparen.', link: '/shop', sort: 2, active: 1 },
{ image: img('slide-saison'), headline: 'Schnittmuster der Saison', subline: 'Vom Sommerkleid bis zum Lieblingsshirt.', link: '/produkt/schnittmuster-sommerkleid-lotta', sort: 3, active: 1 },
];
const legalBody = (title, extra) => `<h2>${title}</h2>\n<p><strong>Brittas Nähkiste</strong><br>Britta Mustermann<br>Musterstraße 3<br>21680 Musterort</p>\n<p>E-Mail: hallo@brittas-naehkiste.de<br>Telefon: 0 41 41 / 12 34 56</p>\n${extra}`;
export const SEED_PAGES = [
{ slug: 'ueber-uns', title: 'Über uns', type: 'content', active: 1, sort: 1,
body: `<h2>Willkommen in Brittas Nähkiste</h2>\n<p>Seit vielen Jahren liefern wir Stoffe, Garne und Kurzwaren an Hobby-Näher:innen und Profis. Unser kleines Team wählt jedes Produkt mit Sorgfalt aus — von der Meterware bis zur Stoffschere.</p>\n<p>Wir lieben es, wenn aus einer Idee ein fertiges Stück wird. Dabei begleiten wir dich mit ehrlicher Beratung, fairer Auswahl und schnellem Versand.</p>\n<p><em>Hinweis: Brittas Nähkiste ist die mitgelieferte Demo-Instanz von hd-commerce. Inhalte, Name und Farben sind im Admin frei anpassbar.</em></p>` },
{ slug: 'impressum', title: 'Impressum', type: 'legal', active: 1, sort: 2,
body: legalBody('Impressum', `<p>Umsatzsteuer-ID: DE000000000<br>Inhaltlich verantwortlich gemäß § 18 Abs. 2 MStV: Britta Mustermann, Anschrift wie oben.</p>\n<p>Dies ist ein neutraler Platzhaltertext der Demo-Instanz.</p>`) },
{ slug: 'datenschutz', title: 'Datenschutz', type: 'legal', active: 1, sort: 3,
body: legalBody('Datenschutzerklärung', `<p>Wir nehmen den Schutz deiner Daten ernst. Personenbezogene Daten werden nur im Rahmen der Bestellabwicklung verarbeitet. Die First-Party-Statistik erhebt keine personenbezogenen Rohdaten.</p>\n<p>Dies ist ein neutraler Platzhaltertext der Demo-Instanz und ersetzt keine Rechtsberatung.</p>`) },
{ slug: 'agb', title: 'AGB', type: 'legal', active: 1, sort: 4,
body: legalBody('Allgemeine Geschäftsbedingungen', `<p>Es gelten die gesetzlichen Bestimmungen. Preise verstehen sich inkl. gesetzlicher MwSt. zzgl. Versandkosten.</p>\n<p>Dies ist ein neutraler Platzhaltertext der Demo-Instanz.</p>`) },
{ slug: 'widerruf', title: 'Widerrufsrecht', type: 'legal', active: 1, sort: 5,
body: legalBody('Widerrufsbelehrung', `<p>Verbraucher:innen haben ein vierzehntägiges Widerrufsrecht. Meterware, die nach Kundenwunsch zugeschnitten wurde, kann vom Widerruf ausgeschlossen sein.</p>\n<p>Dies ist ein neutraler Platzhaltertext der Demo-Instanz.</p>`) },
];
export const SEED_POPUPS = [
{ title: 'Newsletter — 10 % Willkommensrabatt', type: 'newsletter', headline: '10 % auf deine erste Bestellung',
body: 'Trag dich in unseren Newsletter ein und erhalte 10 % Rabatt auf deine erste Bestellung in Brittas Nähkiste.',
image: '', cta_text: 'Rabatt sichern', cta_url: '', trigger: 'exit', trigger_value: '0',
target_path: '/', freq: 'days7', active: 1, sort: 1 },
];
+362
View File
@@ -0,0 +1,362 @@
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 } };
}
+2
View File
@@ -0,0 +1,2 @@
// Facade — hd-commerce nutzt ausschliesslich den SQLite-Treiber.
export * from './store-sqlite.js';