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
+100
View File
@@ -0,0 +1,100 @@
---
import Admin from '../../../layouts/Admin.astro';
import { analyticsSummary, formatPrice, getSetting } from '../../../lib/store.js';
const days = parseInt(new URL(Astro.request.url).searchParams.get('days') || '30') || 30;
const a = analyticsSummary(days);
const accent = getSetting('brand_accent', '#b8566a');
const maxFunnel = Math.max(...a.funnel.map(f => f.value), 1);
const kpis = [
{ label: 'Besucher', val: a.visitors.toLocaleString('de-DE') },
{ label: 'Seitenaufrufe', val: a.pageviews.toLocaleString('de-DE') },
{ label: 'Conversion-Rate', val: a.conversion.toFixed(1) + ' %' },
{ label: 'Ø Bestellwert', val: formatPrice(Math.round(a.aov)) },
];
const maxRev = Math.max(...a.bySource.map(s => s.revenue), 1);
const seriesJson = JSON.stringify(a.series);
---
<Admin title="Analytics" active="analytics" crumbs={[{ label: 'Analytics' }]}>
<div slot="actions" style="display:flex;gap:6px">
{[7, 30, 90].map((d) => (<a class={`s-btn s-btn-sm ${days === d ? 's-btn-primary' : ''}`} href={`/admin/analytics?days=${d}`}>{d} Tage</a>))}
</div>
<div class="s-stack">
<div class="s-kpis">
{kpis.map((k) => (<div class="s-kpi"><div class="s-kpi-label">{k.label}</div><div class="s-kpi-val">{k.val}</div><div class="s-kpi-sub">letzte {days} Tage</div></div>))}
</div>
<div class="s-card">
<div class="s-card-head">Aufrufe & Umsatz (Zeitreihe)</div>
<div class="s-card-pad"><canvas id="tsChart" height="90"></canvas></div>
</div>
<div class="s-grid" style="grid-template-columns:1fr 1fr">
<div class="s-card">
<div class="s-card-head">Conversion-Funnel</div>
<div class="s-card-pad">
<div class="s-funnel">
{a.funnel.map((f) => (
<div class="s-funnel-row">
<div class="fl">{f.label}</div>
<div class="s-funnel-bar"><div class="s-funnel-fill" style={`width:${Math.max(4, (f.value / maxFunnel) * 100)}%`}>{f.value}</div></div>
</div>
))}
</div>
</div>
</div>
<div class="s-card">
<div class="s-card-head">Umsatz pro Quelle</div>
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Quelle</th><th class="num">Besucher</th><th class="num">Käufe</th><th class="num">Umsatz</th></tr></thead>
<tbody>
{a.bySource.map((s) => (
<tr><td><b>{s.src}</b><div class="s-bar-track" style="margin-top:6px"><i style={`width:${(s.revenue / maxRev) * 100}%`}></i></div></td><td class="num">{s.visitors}</td><td class="num">{s.purchases}</td><td class="num"><b>{formatPrice(s.revenue)}</b></td></tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<div class="s-card">
<div class="s-card-head">Top-Produkte (Ansichten → Käufe)</div>
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Produkt</th><th class="num">Ansichten</th><th class="num">Käufe</th><th class="num">Conversion</th></tr></thead>
<tbody>
{a.topProducts.length === 0 ? (<tr><td colspan="4" class="s-empty">Noch keine Produktdaten</td></tr>) :
a.topProducts.map((p) => (
<tr><td><b>{p.name}</b></td><td class="num">{p.views}</td><td class="num">{p.buys}</td><td class="num"><span class={`s-badge ${p.rate >= 5 ? 'green' : 'gray'}`}>{p.rate.toFixed(1)} %</span></td></tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js" is:inline></script>
<script is:inline define:vars={{ seriesJson, accent }}>
(function () {
var data = JSON.parse(seriesJson);
var ctx = document.getElementById('tsChart');
if (!ctx || !window.Chart) return;
new Chart(ctx, {
type: 'line',
data: {
labels: data.map(function (d) { return d.label; }),
datasets: [
{ label: 'Aufrufe', data: data.map(function (d) { return d.views; }), borderColor: accent, backgroundColor: accent + '22', fill: true, tension: 0.35, yAxisID: 'y' },
{ label: 'Umsatz (€)', data: data.map(function (d) { return (d.revenue / 100); }), borderColor: '#444', borderDash: [4, 4], tension: 0.35, yAxisID: 'y1', fill: false }
]
},
options: {
responsive: true, interaction: { mode: 'index', intersect: false },
plugins: { legend: { position: 'bottom' } },
scales: { y: { beginAtZero: true, position: 'left' }, y1: { beginAtZero: true, position: 'right', grid: { drawOnChartArea: false } } }
}
});
})();
</script>
</Admin>
+60
View File
@@ -0,0 +1,60 @@
---
import Admin from '../../../layouts/Admin.astro';
import { getOrderById, updateOrderStatus, formatPrice } from '../../../lib/store.js';
const { id } = Astro.params;
let flash = '';
if (Astro.request.method === 'POST') {
const form = await Astro.request.formData();
const status = form.get('status');
if (status) { updateOrderStatus(id, String(status)); flash = 'Status aktualisiert.'; }
}
const order = getOrderById(id);
if (!order) return Astro.redirect('/admin/bestellungen');
const statusMap = { fulfilled: ['green', 'Erfüllt'], pending: ['amber', 'Offen'], cancelled: ['gray', 'Storniert'], refunded: ['red', 'Erstattet'] };
const fmtDate = (s) => new Date(s).toLocaleString('de-DE', { day: '2-digit', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' });
const statuses = [['pending', 'Offen'], ['fulfilled', 'Erfüllt'], ['cancelled', 'Storniert'], ['refunded', 'Erstattet']];
---
<Admin title={`Bestellung ${order.number}`} active="bestellungen" crumbs={[{ label: 'Bestellungen', href: '/admin/bestellungen' }, { label: order.number }]}>
<div class="s-stack">
{flash && <div class="s-flash">✓ {flash}</div>}
<div class="s-two-col">
<div class="s-card">
<div class="s-card-head">Artikel</div>
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Produkt</th><th>Variante</th><th class="num">Menge</th><th class="num">Einzelpreis</th><th class="num">Summe</th></tr></thead>
<tbody>
{order.items.map((i) => (
<tr><td><b>{i.name}</b></td><td class="s-muted">{i.size || '—'}</td><td class="num">{i.qty}</td><td class="num">{formatPrice(i.priceCents)}</td><td class="num">{formatPrice(i.priceCents * i.qty)}</td></tr>
))}
</tbody>
</table>
</div>
<div class="s-card-pad" style="display:flex;justify-content:space-between;font-weight:700;font-size:16px;border-top:1px solid var(--s-border)"><span>Gesamt</span><span>{formatPrice(order.total_cents)}</span></div>
</div>
<div class="s-stack">
<div class="s-card s-card-pad">
<div class="s-section-title">Status</div>
<div style="margin:8px 0 14px"><span class={`s-badge ${(statusMap[order.status]||['gray',order.status])[0]}`}>{(statusMap[order.status]||['',order.status])[1]}</span></div>
<form method="POST">
<select class="s-select" name="status" style="margin-bottom:10px">
{statuses.map(([v, l]) => (<option value={v} selected={order.status === v}>{l}</option>))}
</select>
<button class="s-btn s-btn-primary" type="submit" style="width:100%">Status speichern</button>
</form>
</div>
<div class="s-card s-card-pad">
<div class="s-section-title">Kunde</div>
<p style="margin:8px 0 4px"><b>{order.customer_name || '—'}</b></p>
<p class="s-muted" style="margin:0 0 8px">{order.email}</p>
<div class="s-section-title" style="margin-top:12px">Lieferadresse</div>
<p class="s-muted" style="margin:6px 0 0">{order.address || '—'}</p>
<div class="s-section-title" style="margin-top:12px">Bestellt am</div>
<p class="s-muted" style="margin:6px 0 0">{fmtDate(order.created_at)}</p>
</div>
</div>
</div>
</div>
</Admin>
+29
View File
@@ -0,0 +1,29 @@
---
import Admin from '../../../layouts/Admin.astro';
import { listOrders, formatPrice } from '../../../lib/store.js';
const orders = listOrders();
const statusMap = { fulfilled: ['green', 'Erfüllt'], pending: ['amber', 'Offen'], cancelled: ['gray', 'Storniert'], refunded: ['red', 'Erstattet'] };
const fmtDate = (s) => new Date(s).toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' });
---
<Admin title="Bestellungen" active="bestellungen" crumbs={[{ label: 'Bestellungen' }]}>
<div class="s-card">
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Bestellung</th><th>Datum</th><th>Kunde</th><th>Artikel</th><th>Status</th><th class="num">Betrag</th></tr></thead>
<tbody>
{orders.length === 0 ? (<tr><td colspan="6" class="s-empty">Noch keine Bestellungen</td></tr>) :
orders.map((o) => (
<tr class="clk" onclick={`location.href='/admin/bestellungen/${o.id}'`}>
<td><b>{o.number}</b></td>
<td class="s-muted">{fmtDate(o.created_at)}</td>
<td>{o.customer_name || '—'}<div class="s-muted" style="font-size:12px">{o.email}</div></td>
<td class="s-muted">{o.items.reduce((s, i) => s + (i.qty || 1), 0)} Stk.</td>
<td><span class={`s-badge ${(statusMap[o.status]||['gray',o.status])[0]}`}>{(statusMap[o.status]||['',o.status])[1]}</span></td>
<td class="num">{formatPrice(o.total_cents)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Admin>
+74
View File
@@ -0,0 +1,74 @@
---
import Admin from '../../../layouts/Admin.astro';
import { getSettings, setSetting } from '../../../lib/store.js';
let flash = '';
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData();
setSetting('shop_name', f.get('shop_name') || 'hd-commerce');
setSetting('shop_tagline', f.get('shop_tagline') || '');
setSetting('shop_email', f.get('shop_email') || '');
setSetting('brand_accent', f.get('brand_accent') || '#b8566a');
setSetting('brand_accent_dark', f.get('brand_accent_dark') || '#8d3f50');
setSetting('currency', f.get('currency') || 'EUR');
setSetting('free_shipping_cents', String(Math.round(parseFloat(String(f.get('free_shipping') || '49').replace(',', '.')) * 100) || 4900));
flash = 'Einstellungen gespeichert.';
}
const s = getSettings();
const stripeSecret = (process.env.STRIPE_SECRET_KEY || '').trim();
const stripeReal = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test(stripeSecret);
const stripeMode = stripeReal ? (stripeSecret.startsWith('sk_live') ? 'Live' : 'Test') : 'Demo-Fallback';
const freeShipStr = ((Number(s.free_shipping_cents) || 4900) / 100).toFixed(2).replace('.', ',');
const currencies = ['EUR', 'CHF', 'USD', 'GBP'];
---
<Admin title="Einstellungen" active="einstellungen" crumbs={[{ label: 'Einstellungen' }]}>
<div class="s-stack">
{flash && <div class="s-flash">✓ {flash}</div>}
<form method="POST" class="s-two-col">
<div class="s-stack">
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:14px;font-size:15px">Shop</div>
<div class="s-field"><label class="s-label">Shop-Name</label><input class="s-input" name="shop_name" value={s.shop_name || ''} required /></div>
<div class="s-field"><label class="s-label">Tagline</label><input class="s-input" name="shop_tagline" value={s.shop_tagline || ''} /></div>
<div class="s-field"><label class="s-label">Kontakt-E-Mail</label><input class="s-input" name="shop_email" type="email" value={s.shop_email || ''} /></div>
</div>
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:14px;font-size:15px">Branding</div>
<div class="s-form-grid">
<div class="s-field"><label class="s-label">Akzentfarbe</label><input class="s-input" name="brand_accent" type="color" value={s.brand_accent || '#b8566a'} /></div>
<div class="s-field"><label class="s-label">Akzentfarbe (dunkel)</label><input class="s-input" name="brand_accent_dark" type="color" value={s.brand_accent_dark || '#8d3f50'} /></div>
</div>
<div class="s-help">Die Akzentfarbe wird im Storefront und im Admin als CSS-Variable injiziert.</div>
</div>
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:14px;font-size:15px">Verkauf</div>
<div class="s-form-grid">
<div class="s-field"><label class="s-label">Währung</label><select class="s-select" name="currency">{currencies.map((c) => (<option value={c} selected={s.currency === c}>{c}</option>))}</select></div>
<div class="s-field"><label class="s-label">Gratis-Versand ab (€)</label><input class="s-input" name="free_shipping" value={freeShipStr} /></div>
</div>
</div>
<button class="s-btn s-btn-primary" type="submit" style="align-self:flex-start">Alle Einstellungen speichern</button>
</div>
<div class="s-stack">
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:12px">Zahlung (Stripe)</div>
<p style="margin:0 0 8px"><span class={`s-badge ${stripeReal ? 'green' : 'amber'}`}>{stripeMode}</span></p>
<p class="s-help">{stripeReal ? 'Echter Stripe-Schlüssel erkannt — Checkout nutzt Stripe Hosted Checkout.' : 'Kein echter STRIPE_SECRET_KEY gesetzt. Der Checkout läuft im Demo-Fallback (Bestellung ohne Zahlung).'}</p>
<p class="s-help" style="margin-top:8px">Konfiguration über ENV: <b>STRIPE_SECRET_KEY</b>, <b>STRIPE_PUBLIC_KEY</b>.</p>
</div>
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:12px">Analytics</div>
<p class="s-help">hd-commerce nutzt eine eigene First-Party-Statistik (events-Tabelle). Kein externer Dienst, keine personenbezogenen Rohdaten — die Session-Kennung ist ein täglich rollender Hash.</p>
</div>
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:12px">System</div>
<p class="s-help">Datenbank: SQLite (<b>DB_PATH</b>). Admin-Zugang über <b>ADMIN_USER</b> / <b>ADMIN_PASS</b>.</p>
</div>
</div>
</form>
</div>
</Admin>
+75
View File
@@ -0,0 +1,75 @@
---
import Admin from '../../layouts/Admin.astro';
import { dashboard, formatPrice } from '../../lib/store.js';
const d = dashboard();
const statusMap = { fulfilled: ['green', 'Erfüllt'], pending: ['amber', 'Offen'], cancelled: ['gray', 'Storniert'], refunded: ['red', 'Erstattet'] };
const fmtDate = (s) => new Date(s).toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' });
const kpis = [
{ label: 'Umsatz (gesamt)', val: formatPrice(d.revenueCents), sub: `${d.orderCount} Bestellungen` },
{ label: 'Bestellungen', val: d.orderCount, sub: `${d.pending} offen` },
{ label: 'Produkte', val: d.productCount, sub: 'aktiv im Shop' },
{ label: 'Kunden', val: d.customerCount, sub: 'registriert' },
];
---
<Admin title="Dashboard" active="dashboard">
<a slot="actions" class="s-btn s-btn-primary" href="/admin/produkte/neu">+ Produkt</a>
<div class="s-stack">
<div class="s-kpis">
{kpis.map((k) => (
<div class="s-kpi"><div class="s-kpi-label">{k.label}</div><div class="s-kpi-val">{k.val}</div><div class="s-kpi-sub">{k.sub}</div></div>
))}
</div>
<div class="s-card">
<div class="s-card-head">First-Party-Funnel (30 Tage)<a class="s-link" href="/admin/analytics">Details</a></div>
<div class="s-card-pad">
<div class="s-funnel-mini">
<div class="s-fm-step"><div class="v">{d.funnelMini.views}</div><div class="l">Aufrufe</div></div>
<div class="s-fm-arrow">→</div>
<div class="s-fm-step"><div class="v">{d.funnelMini.cart}</div><div class="l">In den Korb</div></div>
<div class="s-fm-arrow">→</div>
<div class="s-fm-step"><div class="v">{d.funnelMini.buy}</div><div class="l">Kauf</div></div>
</div>
</div>
</div>
<div class="s-grid" style="grid-template-columns:1.4fr 1fr">
<div class="s-card">
<div class="s-card-head">Neueste Bestellungen<a class="s-link" href="/admin/bestellungen">Alle</a></div>
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Bestellung</th><th>Kunde</th><th>Status</th><th class="num">Betrag</th></tr></thead>
<tbody>
{d.recentOrders.map((o) => (
<tr class="clk" onclick={`location.href='/admin/bestellungen/${o.id}'`}>
<td><b>{o.number}</b><div class="s-muted" style="font-size:12px">{fmtDate(o.created_at)}</div></td>
<td>{o.customer_name || '—'}</td>
<td><span class={`s-badge ${(statusMap[o.status]||['gray',o.status])[0]}`}>{(statusMap[o.status]||['',o.status])[1]}</span></td>
<td class="num">{formatPrice(o.total_cents)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div class="s-card">
<div class="s-card-head">Geringer Bestand</div>
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Produkt</th><th class="num">Bestand</th></tr></thead>
<tbody>
{d.lowStock.length === 0 ? (<tr><td colspan="2" class="s-empty">Alles gut bestückt 👍</td></tr>) :
d.lowStock.map((p) => (
<tr class="clk" onclick={`location.href='/admin/produkte/${p.id}'`}>
<td><div class="s-prodcell">{p.cardImage && <img src={p.cardImage} alt="" />}<span class="nm">{p.shortName || p.name}</span></div></td>
<td class="num"><span class={`s-badge ${p.stock <= 10 ? 'red' : 'amber'}`}>{p.stock}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</Admin>
+178
View File
@@ -0,0 +1,178 @@
---
import Admin from '../../../layouts/Admin.astro';
import {
listPages, createPage, updatePage, deletePage, getPageById,
listSlides, createSlide, updateSlide, deleteSlide, getSlideById,
listMedia,
} from '../../../lib/store.js';
let flash = '';
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData();
const action = f.get('_action');
if (action === 'page') {
const data = { slug: f.get('slug') || '', title: f.get('title') || '', body: f.get('body') || '', type: f.get('type') || 'content', active: f.get('active') === 'on', sort: parseInt(String(f.get('sort') || '99')) || 99 };
const id = f.get('id');
if (id) updatePage(id, data); else createPage(data);
return Astro.redirect('/admin/inhalte?tab=pages&saved=1');
} else if (action === 'delete-page') { deletePage(f.get('id')); return Astro.redirect('/admin/inhalte?tab=pages'); }
else if (action === 'slide') {
const data = { image: f.get('image') || '', headline: f.get('headline') || '', subline: f.get('subline') || '', link: f.get('link') || '', sort: parseInt(String(f.get('sort') || '99')) || 99, active: f.get('active') === 'on' };
const id = f.get('id');
if (id) updateSlide(id, data); else createSlide(data);
return Astro.redirect('/admin/inhalte?tab=slider&saved=1');
} else if (action === 'delete-slide') { deleteSlide(f.get('id')); return Astro.redirect('/admin/inhalte?tab=slider'); }
}
const url = new URL(Astro.request.url);
const tab = url.searchParams.get('tab') || 'pages';
if (url.searchParams.get('saved')) flash = 'Gespeichert.';
const editPageId = url.searchParams.get('editpage');
const editSlideId = url.searchParams.get('editslide');
const pages = listPages();
const slides = listSlides();
const media = listMedia();
const ep = editPageId ? getPageById(editPageId) : null;
const pg = ep || { id: '', slug: '', title: '', body: '', type: 'content', active: 1, sort: 99 };
const es = editSlideId ? getSlideById(editSlideId) : null;
const sl = es || { id: '', image: '', headline: '', subline: '', link: '', sort: 99, active: 1 };
const tabs = [['pages', 'Seiten & Rechtstexte'], ['slider', 'Slider'], ['media', 'Medien']];
---
<Admin title="Inhalte" active="inhalte" crumbs={[{ label: 'Inhalte' }]}>
<div class="s-stack">
{flash && <div class="s-flash">✓ {flash}</div>}
<div class="s-tabs">
{tabs.map(([v, l]) => (<a class={`s-tab ${tab === v ? 'active' : ''}`} href={`/admin/inhalte?tab=${v}`}>{l}</a>))}
</div>
{tab === 'pages' && (
<div class="s-two-col">
<div class="s-card">
<div class="s-card-head">Seiten</div>
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Titel</th><th>Slug</th><th>Typ</th><th>Status</th><th></th></tr></thead>
<tbody>
{pages.map((p) => (
<tr>
<td><b>{p.title}</b></td>
<td class="s-muted">/seite/{p.slug}</td>
<td><span class={`s-badge ${p.type === 'legal' ? 'blue' : 'gray'}`}>{p.type === 'legal' ? 'Rechtstext' : 'Inhalt'}</span></td>
<td>{p.active ? <span class="s-badge green">Aktiv</span> : <span class="s-badge gray">Aus</span>}</td>
<td class="num">
<a class="s-btn s-btn-sm" href={`/admin/inhalte?tab=pages&editpage=${p.id}`}>Bearbeiten</a>
<form method="POST" style="display:inline" onsubmit="return confirm('Seite löschen?')"><input type="hidden" name="_action" value="delete-page" /><input type="hidden" name="id" value={p.id} /><button class="s-btn s-btn-sm s-btn-danger">Löschen</button></form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:12px">{ep ? 'Seite bearbeiten' : 'Seite anlegen'}</div>
<form method="POST">
<input type="hidden" name="_action" value="page" />
{ep && <input type="hidden" name="id" value={pg.id} />}
<div class="s-field"><label class="s-label">Titel</label><input class="s-input" name="title" value={pg.title} required /></div>
<div class="s-form-grid">
<div class="s-field"><label class="s-label">Slug</label><input class="s-input" name="slug" value={pg.slug} required /></div>
<div class="s-field"><label class="s-label">Typ</label><select class="s-select" name="type"><option value="content" selected={pg.type === 'content'}>Inhalt</option><option value="legal" selected={pg.type === 'legal'}>Rechtstext</option></select></div>
</div>
<div class="s-field"><label class="s-label">Inhalt (HTML/Markdown)</label><textarea class="s-textarea" name="body" style="min-height:220px">{pg.body}</textarea></div>
<div class="s-form-grid">
<div class="s-field"><label class="s-label">Reihenfolge</label><input class="s-input" name="sort" type="number" value={pg.sort} /></div>
<div class="s-field"><label class="s-label">Status</label><label class="s-check" style="margin-top:8px"><input type="checkbox" name="active" checked={!!pg.active} /> Aktiv</label></div>
</div>
<button class="s-btn s-btn-primary" type="submit" style="width:100%">{ep ? 'Speichern' : 'Anlegen'}</button>
{ep && <a class="s-btn" href="/admin/inhalte?tab=pages" style="width:100%;justify-content:center;margin-top:8px">Abbrechen</a>}
</form>
</div>
</div>
)}
{tab === 'slider' && (
<div class="s-two-col">
<div class="s-card">
<div class="s-card-head">Slides</div>
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Bild</th><th>Headline</th><th>Reihenf.</th><th>Status</th><th></th></tr></thead>
<tbody>
{slides.map((s) => (
<tr>
<td><div class="s-prodcell">{s.image && <img src={s.image} alt="" style="width:54px;height:34px;object-fit:cover" />}</div></td>
<td><b>{s.headline}</b><div class="s-muted" style="font-size:12px">{s.subline}</div></td>
<td class="s-muted">{s.sort}</td>
<td>{s.active ? <span class="s-badge green">Aktiv</span> : <span class="s-badge gray">Aus</span>}</td>
<td class="num">
<a class="s-btn s-btn-sm" href={`/admin/inhalte?tab=slider&editslide=${s.id}`}>Bearbeiten</a>
<form method="POST" style="display:inline" onsubmit="return confirm('Slide löschen?')"><input type="hidden" name="_action" value="delete-slide" /><input type="hidden" name="id" value={s.id} /><button class="s-btn s-btn-sm s-btn-danger">Löschen</button></form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:12px">{es ? 'Slide bearbeiten' : 'Slide anlegen'}</div>
<form method="POST">
<input type="hidden" name="_action" value="slide" />
{es && <input type="hidden" name="id" value={sl.id} />}
<div class="s-field"><label class="s-label">Bild-URL</label><input class="s-input" name="image" value={sl.image} /></div>
<div class="s-field"><label class="s-label">Headline</label><input class="s-input" name="headline" value={sl.headline} required /></div>
<div class="s-field"><label class="s-label">Subline</label><input class="s-input" name="subline" value={sl.subline} /></div>
<div class="s-field"><label class="s-label">Link</label><input class="s-input" name="link" value={sl.link} placeholder="/shop" /></div>
<div class="s-form-grid">
<div class="s-field"><label class="s-label">Reihenfolge</label><input class="s-input" name="sort" type="number" value={sl.sort} /></div>
<div class="s-field"><label class="s-label">Status</label><label class="s-check" style="margin-top:8px"><input type="checkbox" name="active" checked={!!sl.active} /> Aktiv</label></div>
</div>
<button class="s-btn s-btn-primary" type="submit" style="width:100%">{es ? 'Speichern' : 'Anlegen'}</button>
{es && <a class="s-btn" href="/admin/inhalte?tab=slider" style="width:100%;justify-content:center;margin-top:8px">Abbrechen</a>}
</form>
</div>
</div>
)}
{tab === 'media' && (
<div class="s-stack">
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:12px">Medien hochladen</div>
<input type="file" id="mediaFile" accept="image/*" class="s-input" style="padding:8px" />
<div id="upMsg" class="s-help" style="margin-top:8px"></div>
</div>
<div class="s-card">
<div class="s-card-head">Medienbibliothek</div>
<div class="s-card-pad">
{media.length === 0 ? (<div class="s-empty">Noch keine Medien hochgeladen</div>) : (
<div class="s-media-grid">
{media.map((m) => (
<div class="s-media-item">
<img src={m.url} alt={m.filename} />
<div class="mi"><span class="s-muted">{Math.round((m.size || 0) / 1024)} KB</span><button class="s-btn s-btn-sm" type="button" onclick={`navigator.clipboard.writeText(location.origin+'${m.url}');this.textContent='Kopiert!'`}>URL kopieren</button></div>
</div>
))}
</div>
)}
</div>
</div>
<script is:inline>
(function () {
var inp = document.getElementById('mediaFile');
if (!inp) return;
inp.addEventListener('change', function () {
var file = inp.files[0]; if (!file) return;
var msg = document.getElementById('upMsg'); msg.textContent = 'Lädt hoch …';
var fd = new FormData(); fd.append('file', file);
fetch('/api/upload', { method: 'POST', body: fd }).then(function (r) { return r.json(); }).then(function (d) {
if (d.ok) { msg.textContent = 'Hochgeladen: ' + d.url; setTimeout(function () { location.reload(); }, 600); }
else { msg.textContent = 'Fehler: ' + (d.error || 'unbekannt'); }
}).catch(function () { msg.textContent = 'Upload fehlgeschlagen.'; });
});
})();
</script>
</div>
)}
</div>
</Admin>
+28
View File
@@ -0,0 +1,28 @@
---
import Admin from '../../../layouts/Admin.astro';
import { listCustomers, formatPrice } from '../../../lib/store.js';
const customers = listCustomers().sort((a, b) => b.total_spent_cents - a.total_spent_cents);
const fmtDate = (s) => s ? new Date(s).toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' }) : '—';
---
<Admin title="Kunden" active="kunden" crumbs={[{ label: 'Kunden' }]}>
<div class="s-card">
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Name</th><th>E-Mail</th><th>Ort</th><th class="num">Bestellungen</th><th class="num">Umsatz</th><th>Seit</th></tr></thead>
<tbody>
{customers.length === 0 ? (<tr><td colspan="6" class="s-empty">Noch keine Kunden</td></tr>) :
customers.map((c) => (
<tr>
<td><b>{c.name || '—'}</b></td>
<td class="s-muted">{c.email}</td>
<td class="s-muted">{c.city || '—'}</td>
<td class="num">{c.orders_count}</td>
<td class="num"><b>{formatPrice(c.total_spent_cents)}</b></td>
<td class="s-muted">{fmtDate(c.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Admin>
+112
View File
@@ -0,0 +1,112 @@
---
import Admin from '../../../layouts/Admin.astro';
import { listPopups, createPopup, updatePopup, deletePopup, getPopupById, setSetting, getSettings } from '../../../lib/store.js';
let flash = '';
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData();
const action = f.get('_action');
if (action === 'announce') {
setSetting('announcement_text', f.get('announcement_text') || '');
setSetting('announcement_link', f.get('announcement_link') || '/shop');
setSetting('announcement_active', f.get('announcement_active') === 'on' ? '1' : '0');
flash = 'Announcement-Bar gespeichert.';
} else if (action === 'delete-popup') {
deletePopup(f.get('id')); return Astro.redirect('/admin/marketing');
} else if (action === 'popup') {
const data = {
title: f.get('title') || '', type: f.get('type') || 'newsletter', headline: f.get('headline') || '', body: f.get('body') || '',
image: f.get('image') || '', cta_text: f.get('cta_text') || '', cta_url: f.get('cta_url') || '',
trigger: f.get('trigger') || 'delay', trigger_value: parseInt(String(f.get('trigger_value') || '0')) || 0,
target_path: f.get('target_path') || '/', freq: f.get('freq') || 'session', active: f.get('active') === 'on',
sort: parseInt(String(f.get('sort') || '99')) || 99,
};
const editId = f.get('id');
if (editId) { updatePopup(editId, data); flash = 'Popup gespeichert.'; }
else { createPopup(data); flash = 'Popup angelegt.'; }
}
}
const settings = getSettings();
const popups = listPopups();
const editId = new URL(Astro.request.url).searchParams.get('edit');
const editing = editId ? getPopupById(editId) : null;
const e = editing || { id: '', title: '', type: 'newsletter', headline: '', body: '', image: '', cta_text: '', cta_url: '', trigger: 'exit', trigger_value: 0, target_path: '/', freq: 'days7', active: 1, sort: 1 };
const triggers = [['delay', 'Verzögerung (Sek.)'], ['scroll', 'Scroll-Tiefe (%)'], ['exit', 'Exit-Intent']];
const freqs = [['session', 'Pro Session'], ['days7', 'Alle 7 Tage'], ['always', 'Immer']];
const types = [['newsletter', 'Newsletter'], ['discount', 'Rabatt'], ['announcement', 'Ankündigung'], ['exit', 'Exit-Angebot']];
---
<Admin title="Marketing" active="marketing" crumbs={[{ label: 'Marketing' }]}>
<div class="s-stack">
{flash && <div class="s-flash">✓ {flash}</div>}
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:14px;font-size:15px">Announcement-Bar</div>
<form method="POST" class="s-form-grid">
<input type="hidden" name="_action" value="announce" />
<div class="s-field full"><label class="s-label">Text</label><input class="s-input" name="announcement_text" value={settings.announcement_text || ''} /></div>
<div class="s-field"><label class="s-label">Link</label><input class="s-input" name="announcement_link" value={settings.announcement_link || '/shop'} /></div>
<div class="s-field"><label class="s-label">Status</label><label class="s-check" style="margin-top:8px"><input type="checkbox" name="announcement_active" checked={settings.announcement_active === '1'} /> Aktiv anzeigen</label></div>
<div class="s-field full"><button class="s-btn s-btn-primary" type="submit">Announcement speichern</button></div>
</form>
</div>
<div class="s-two-col">
<div class="s-card">
<div class="s-card-head">Popups<a class="s-link" href="/admin/marketing">+ Neu</a></div>
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Titel</th><th>Typ</th><th>Trigger</th><th>Pfad</th><th>Status</th><th></th></tr></thead>
<tbody>
{popups.length === 0 ? (<tr><td colspan="6" class="s-empty">Keine Popups</td></tr>) :
popups.map((pp) => (
<tr>
<td><b>{pp.title}</b></td>
<td class="s-muted">{pp.type}</td>
<td class="s-muted">{pp.trigger}</td>
<td class="s-muted">{pp.target_path}</td>
<td>{pp.active ? <span class="s-badge green">Aktiv</span> : <span class="s-badge gray">Inaktiv</span>}</td>
<td class="num">
<a class="s-btn s-btn-sm" href={`/admin/marketing?edit=${pp.id}`}>Bearbeiten</a>
<form method="POST" style="display:inline" onsubmit="return confirm('Popup löschen?')"><input type="hidden" name="_action" value="delete-popup" /><input type="hidden" name="id" value={pp.id} /><button class="s-btn s-btn-sm s-btn-danger">Löschen</button></form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:12px">{editing ? 'Popup bearbeiten' : 'Popup anlegen'}</div>
<form method="POST">
<input type="hidden" name="_action" value="popup" />
{editing && <input type="hidden" name="id" value={e.id} />}
<div class="s-field"><label class="s-label">Interner Titel</label><input class="s-input" name="title" value={e.title} required /></div>
<div class="s-field"><label class="s-label">Typ</label><select class="s-select" name="type">{types.map(([v, l]) => (<option value={v} selected={e.type === v}>{l}</option>))}</select></div>
<div class="s-field"><label class="s-label">Headline</label><input class="s-input" name="headline" value={e.headline} /></div>
<div class="s-field"><label class="s-label">Text</label><textarea class="s-textarea" name="body">{e.body}</textarea></div>
<div class="s-field"><label class="s-label">Bild-URL (optional)</label><input class="s-input" name="image" value={e.image} /></div>
<div class="s-form-grid">
<div class="s-field"><label class="s-label">CTA-Text</label><input class="s-input" name="cta_text" value={e.cta_text} /></div>
<div class="s-field"><label class="s-label">CTA-Link</label><input class="s-input" name="cta_url" value={e.cta_url} /></div>
</div>
<div class="s-form-grid">
<div class="s-field"><label class="s-label">Trigger</label><select class="s-select" name="trigger">{triggers.map(([v, l]) => (<option value={v} selected={e.trigger === v}>{l}</option>))}</select></div>
<div class="s-field"><label class="s-label">Trigger-Wert</label><input class="s-input" name="trigger_value" type="number" value={e.trigger_value} /></div>
</div>
<div class="s-form-grid">
<div class="s-field"><label class="s-label">Ziel-Pfad</label><input class="s-input" name="target_path" value={e.target_path} placeholder="/ oder *" /></div>
<div class="s-field"><label class="s-label">Frequenz</label><select class="s-select" name="freq">{freqs.map(([v, l]) => (<option value={v} selected={e.freq === v}>{l}</option>))}</select></div>
</div>
<div class="s-form-grid">
<div class="s-field"><label class="s-label">Reihenfolge</label><input class="s-input" name="sort" type="number" value={e.sort} /></div>
<div class="s-field"><label class="s-label">Status</label><label class="s-check" style="margin-top:8px"><input type="checkbox" name="active" checked={!!e.active} /> Aktiv</label></div>
</div>
<button class="s-btn s-btn-primary" type="submit" style="width:100%">{editing ? 'Speichern' : 'Anlegen'}</button>
{editing && <a class="s-btn" href="/admin/marketing" style="width:100%;justify-content:center;margin-top:8px">Abbrechen</a>}
</form>
</div>
</div>
</div>
</Admin>
+91
View File
@@ -0,0 +1,91 @@
---
import Admin from '../../../layouts/Admin.astro';
import { getProductById, createProduct, updateProduct, listCategories } from '../../../lib/store.js';
const { id } = Astro.params;
const isNew = id === 'neu';
let flash = '';
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData();
const slugify = (s) => s.toLowerCase().replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const data = {
name: String(f.get('name') || ''),
slug: String(f.get('slug') || '') || slugify(String(f.get('name') || 'produkt')),
shortName: String(f.get('shortName') || ''),
priceCents: Math.round(parseFloat(String(f.get('price') || '0').replace(',', '.')) * 100) || 0,
category: String(f.get('category') || ''),
sizes: String(f.get('sizes') || '').split(',').map(s => s.trim()).filter(Boolean),
images: String(f.get('images') || '').split('\n').map(s => s.trim()).filter(Boolean),
cardImage: String(f.get('cardImage') || ''),
badge: String(f.get('badge') || ''),
stock: f.get('stock') === '' ? null : parseInt(String(f.get('stock'))),
material: String(f.get('material') || ''),
features: String(f.get('features') || '').split('\n').map(s => s.trim()).filter(Boolean),
featured: f.get('featured') === 'on',
sort: parseInt(String(f.get('sort') || '99')) || 99,
desc: String(f.get('desc') || ''),
};
if (isNew) { const newId = createProduct(data); return Astro.redirect(`/admin/produkte/${newId}?saved=1`); }
else { updateProduct(id, data); flash = 'Produkt gespeichert.'; }
}
const product = isNew ? null : getProductById(id);
if (!isNew && !product) return Astro.redirect('/admin/produkte');
if (new URL(Astro.request.url).searchParams.get('saved')) flash = 'Produkt angelegt.';
const cats = listCategories();
const p = product || { name: '', slug: '', shortName: '', priceCents: 0, category: '', sizes: ['One Size'], images: [], cardImage: '', badge: '', stock: '', material: '', features: [], featured: false, sort: 99, desc: '' };
const priceStr = product ? (product.priceCents / 100).toFixed(2).replace('.', ',') : '';
---
<Admin title={isNew ? 'Neues Produkt' : (product.shortName || product.name)} active="produkte" crumbs={[{ label: 'Produkte', href: '/admin/produkte' }, { label: isNew ? 'Neu' : (product.shortName || product.name) }]}>
<div class="s-stack">
{flash && <div class="s-flash">✓ {flash}</div>}
<form method="POST" class="s-two-col">
<div class="s-stack">
<div class="s-card s-card-pad">
<div class="s-field"><label class="s-label">Produktname</label><input class="s-input" name="name" value={p.name} required /></div>
<div class="s-form-grid">
<div class="s-field"><label class="s-label">Kurzname (Karte)</label><input class="s-input" name="shortName" value={p.shortName} /></div>
<div class="s-field"><label class="s-label">Slug (URL)</label><input class="s-input" name="slug" value={p.slug} placeholder="auto aus Name" /></div>
</div>
<div class="s-field"><label class="s-label">Beschreibung</label><textarea class="s-textarea" name="desc">{p.desc}</textarea></div>
</div>
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:12px">Medien</div>
<div class="s-field"><label class="s-label">Karten-Bild (URL)</label><input class="s-input" name="cardImage" value={p.cardImage} /></div>
<div class="s-field"><label class="s-label">Galerie-Bilder (eine URL pro Zeile)</label><textarea class="s-textarea" name="images">{(p.images || []).join('\n')}</textarea></div>
</div>
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:12px">Eigenschaften</div>
<div class="s-form-grid">
<div class="s-field"><label class="s-label">Material</label><input class="s-input" name="material" value={p.material} /></div>
<div class="s-field"><label class="s-label">Varianten / Größen (Komma-getrennt)</label><input class="s-input" name="sizes" value={(p.sizes || []).join(', ')} /></div>
</div>
<div class="s-field"><label class="s-label">Features (eine Zeile pro Punkt)</label><textarea class="s-textarea" name="features">{(p.features || []).join('\n')}</textarea></div>
</div>
</div>
<div class="s-stack">
<div class="s-card s-card-pad">
<button class="s-btn s-btn-primary" type="submit" style="width:100%;margin-bottom:8px">{isNew ? 'Produkt anlegen' : 'Speichern'}</button>
<a class="s-btn" href="/admin/produkte" style="width:100%;justify-content:center">Zurück</a>
</div>
<div class="s-card s-card-pad">
<div class="s-field"><label class="s-label">Preis (€)</label><input class="s-input" name="price" value={priceStr} placeholder="0,00" required /></div>
<div class="s-field"><label class="s-label">Kategorie</label>
<input class="s-input" name="category" value={p.category} list="catlist" />
<datalist id="catlist">{cats.map((c) => (<option value={c} />))}</datalist>
</div>
<div class="s-form-grid">
<div class="s-field"><label class="s-label">Bestand</label><input class="s-input" name="stock" type="number" value={p.stock ?? ''} placeholder="∞" /></div>
<div class="s-field"><label class="s-label">Reihenfolge</label><input class="s-input" name="sort" type="number" value={p.sort} /></div>
</div>
<div class="s-field"><label class="s-label">Badge</label><input class="s-input" name="badge" value={p.badge} placeholder="z. B. Neu, Set" /></div>
<label class="s-check"><input type="checkbox" name="featured" checked={p.featured} /> Auf Startseite hervorheben</label>
</div>
</div>
</form>
</div>
</Admin>
+40
View File
@@ -0,0 +1,40 @@
---
import Admin from '../../../layouts/Admin.astro';
import { listProducts, deleteProduct, formatPrice } from '../../../lib/store.js';
if (Astro.request.method === 'POST') {
const form = await Astro.request.formData();
if (form.get('_action') === 'delete' && form.get('id')) { deleteProduct(form.get('id')); return Astro.redirect('/admin/produkte'); }
}
const products = listProducts();
---
<Admin title="Produkte" active="produkte" crumbs={[{ label: 'Produkte' }]}>
<a slot="actions" class="s-btn s-btn-primary" href="/admin/produkte/neu">+ Produkt anlegen</a>
<div class="s-card">
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Produkt</th><th>Kategorie</th><th>Bestand</th><th>Featured</th><th class="num">Preis</th><th></th></tr></thead>
<tbody>
{products.length === 0 ? (<tr><td colspan="6" class="s-empty">Noch keine Produkte</td></tr>) :
products.map((p) => (
<tr>
<td class="clk" onclick={`location.href='/admin/produkte/${p.id}'`}>
<div class="s-prodcell">{p.cardImage && <img src={p.cardImage} alt="" />}<div><div class="nm">{p.shortName || p.name}</div>{p.badge && <span class="s-muted" style="font-size:12px">{p.badge}</span>}</div></div>
</td>
<td class="s-muted">{p.category || '—'}</td>
<td>{p.stock == null ? <span class="s-muted">∞</span> : <span class={`s-badge ${p.stock <= 10 ? 'red' : p.stock <= 35 ? 'amber' : 'green'}`}>{p.stock}</span>}</td>
<td>{p.featured ? <span class="s-badge blue">Ja</span> : <span class="s-muted">—</span>}</td>
<td class="num"><b>{formatPrice(p.priceCents)}</b></td>
<td class="num">
<a class="s-btn s-btn-sm" href={`/admin/produkte/${p.id}`}>Bearbeiten</a>
<form method="POST" style="display:inline" onsubmit="return confirm('Produkt wirklich löschen?')">
<input type="hidden" name="_action" value="delete" /><input type="hidden" name="id" value={p.id} />
<button class="s-btn s-btn-sm s-btn-danger" type="submit">Löschen</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Admin>