hd-commerce: neutrales SQLite-Commerce-Backend (Admin + API + Demo-Storefront)
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user