v2.2: Verkaufsfertig-Fundament — Mollie/Payment-Abstraktion, MwSt/Grundpreis (PAngV), Versandzonen, Bestellmails (Listmonk/SMTP/Log), Feature-Flags
- payments.js: einheitliche createPayment/Webhook-Schnittstelle (Mollie Default, Stripe, Demo); Auto-Provider-Wahl; Mollie-REST + /api/payments/webhook (idempotent); Fake-Key => sauberer Demo-Fallback - mailer.js: sendMail via Listmonk-Tx / SMTP (nodemailer) / Log-Fallback (email_log); gebrandete Bestellbestaetigung bei paid - DACH: products.mwst + base_amount/base_unit/base_price_per (Grundpreis); Storefront/Warenkorb/Checkout/Erfolg/Admin mit MwSt-Ausweis + Versand-Transparenz; tax_cents/shipping_cents/country an Orders - shipping_zones-Tabelle + CRUD + shippingFor(); Admin 'Versand'; serverseitige Versandberechnung in /api/checkout + /api/shipping-quote (Laenderwahl live) - Feature-Flags (feature_*) + feature()-Helper; Admin Module-Toggles; Newsletter-Gating (Popup/Subscribe) - Admin-API/Manifest/ai-admin.txt um shipping_zones erweitert; MCP list/upsert/delete_shipping; README/.env.example ergaenzt; Version 2.2.0
This commit is contained in:
@@ -13,6 +13,7 @@ if (Astro.request.method === 'POST') {
|
||||
}
|
||||
const order = getOrderById(id);
|
||||
if (!order) return Astro.redirect(base + '/bestellungen');
|
||||
const subtotal = order.total_cents + (order.discount_cents || 0) - (order.shipping_cents || 0);
|
||||
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']];
|
||||
@@ -39,7 +40,12 @@ const statuses = [['pending', 'Offen'], ['fulfilled', 'Erfüllt'], ['cancelled',
|
||||
{(order.discount_cents === 0 && order.discount_code) && (
|
||||
<div class="s-card-pad" style="display:flex;justify-content:space-between;color:var(--accent);font-size:14px;border-top:1px solid var(--s-border)"><span>Gutschein ({order.discount_code})</span><span>Gratisversand</span></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 class="s-card-pad" style="display:flex;justify-content:space-between;font-size:14px;color:var(--s-muted,#777);border-top:1px solid var(--s-border)"><span>Zwischensumme (netto + brutto)</span><span>{formatPrice(subtotal)}</span></div>
|
||||
<div class="s-card-pad" style="display:flex;justify-content:space-between;font-size:14px;color:var(--s-muted,#777);padding-top:0"><span>Versand{order.country ? ` (${order.country})` : ''}</span><span>{order.shipping_cents === 0 ? 'Kostenlos' : formatPrice(order.shipping_cents)}</span></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 (brutto)</span><span>{formatPrice(order.total_cents)}</span></div>
|
||||
<div class="s-card-pad" style="display:flex;justify-content:space-between;font-size:13px;color:var(--s-muted,#777);padding-top:0">
|
||||
<span>Netto / enthaltene MwSt</span><span>{formatPrice(order.total_cents - (order.tax_cents||0))} / {formatPrice(order.tax_cents||0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-stack">
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
import Admin from '../../../layouts/Admin.astro';
|
||||
import { adminBase } from '../../../lib/auth.js';
|
||||
const base = adminBase();
|
||||
import { listEmailLog } from '../../../lib/store.js';
|
||||
const logs = listEmailLog(100);
|
||||
const fmtDate = (s) => { try { return new Date(s).toLocaleString('de-DE', { day: '2-digit', month: 'short', year: '2-digit', hour: '2-digit', minute: '2-digit' }); } catch { return s; } };
|
||||
const typeLabel = { order_confirmation: 'Bestellbestätigung', general: 'Allgemein' };
|
||||
const statusBadge = { sent: 'green', logged: 'amber', error: 'red' };
|
||||
const url = new URL(Astro.request.url);
|
||||
const previewId = url.searchParams.get('preview');
|
||||
const preview = previewId ? logs.find((l) => String(l.id) === String(previewId)) : null;
|
||||
---
|
||||
<Admin title="E-Mail-Log" active="einstellungen" crumbs={[{ label: 'Einstellungen', href: base + '/einstellungen' }, { label: 'E-Mail-Log' }]}>
|
||||
<div class="s-stack">
|
||||
<div class="s-card s-card-pad">
|
||||
<p class="s-help" style="margin:0">Letzte gesendete bzw. geloggte Mails. Ohne konfigurierten Provider (Listmonk/SMTP) werden alle Mails hier protokolliert (Status „logged").</p>
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<div class="s-card s-card-pad">
|
||||
<div class="s-section-title" style="margin-bottom:8px">Vorschau — {preview.subject}</div>
|
||||
<p class="s-help" style="margin:0 0 10px">An {preview.recipient} · {fmtDate(preview.created_at)}</p>
|
||||
<iframe srcdoc={preview.html} style="width:100%;height:520px;border:1px solid var(--s-border);border-radius:10px;background:#fff"></iframe>
|
||||
<p style="margin-top:10px"><a class="s-btn s-btn-sm" href={base + '/einstellungen/email-log'}>Vorschau schließen</a></p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="s-card">
|
||||
<div class="s-card-head">Protokoll ({logs.length})</div>
|
||||
<div class="s-table-wrap">
|
||||
<table class="s-table">
|
||||
<thead><tr><th>Datum</th><th>Empfänger</th><th>Betreff</th><th>Typ</th><th>Provider</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{logs.length === 0 ? (<tr><td colspan="7" class="s-empty">Noch keine Mails</td></tr>) :
|
||||
logs.map((l) => (
|
||||
<tr>
|
||||
<td class="s-muted" style="white-space:nowrap">{fmtDate(l.created_at)}</td>
|
||||
<td>{l.recipient}</td>
|
||||
<td><b>{l.subject}</b></td>
|
||||
<td class="s-muted">{typeLabel[l.type] || l.type}</td>
|
||||
<td class="s-muted">{l.provider}</td>
|
||||
<td><span class={`s-badge ${statusBadge[l.status] || 'gray'}`}>{l.status}</span></td>
|
||||
<td style="text-align:right"><a class="s-btn s-btn-sm" href={base + '/einstellungen/email-log?preview=' + l.id}>Ansehen</a></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Admin>
|
||||
@@ -2,27 +2,51 @@
|
||||
import Admin from '../../../layouts/Admin.astro';
|
||||
import { adminBase } from '../../../lib/auth.js';
|
||||
const base = adminBase();
|
||||
import { getSettings, setSetting } from '../../../lib/store.js';
|
||||
import { getSettings, setSetting, resolvePaymentProvider, FEATURE_KEYS, feature } from '../../../lib/store.js';
|
||||
import { mailerStatus } from '../../../lib/mailer.js';
|
||||
|
||||
const FEATURE_LABELS = {
|
||||
feature_newsletter: ['Newsletter', 'Newsletter-Popup & Anmeldung im Storefront'],
|
||||
feature_accounts: ['Kundenkonten', 'Registrierung & Login für Kund:innen (in Vorbereitung)'],
|
||||
feature_reviews: ['Bewertungen', 'Produktbewertungen (in Vorbereitung)'],
|
||||
feature_wishlist: ['Merkliste', 'Wunschliste / Merken (in Vorbereitung)'],
|
||||
feature_abandoned_cart: ['Warenkorb-Erinnerung', 'Abandoned-Cart-Mails (in Vorbereitung)'],
|
||||
feature_search: ['Suche', 'Produktsuche im Storefront (in Vorbereitung)'],
|
||||
};
|
||||
|
||||
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 action = String(f.get('_action') || 'general');
|
||||
if (action === 'features') {
|
||||
for (const k of FEATURE_KEYS) setSetting(k, f.get(k) === 'on' ? '1' : '0');
|
||||
flash = 'Module aktualisiert.';
|
||||
} else if (action === 'payment') {
|
||||
setSetting('payment_provider', String(f.get('payment_provider') || ''));
|
||||
flash = 'Zahlungsanbieter gespeichert.';
|
||||
} else {
|
||||
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'];
|
||||
|
||||
const pp = resolvePaymentProvider();
|
||||
const providerLabel = { mollie: 'Mollie', stripe: 'Stripe', demo: 'Demo-Fallback' }[pp.provider] || pp.provider;
|
||||
const mollieSet = /^(test|live)_\w{20,}/.test((process.env.MOLLIE_API_KEY || '').trim());
|
||||
const stripeSet = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test((process.env.STRIPE_SECRET_KEY || '').trim());
|
||||
const providerSetting = s.payment_provider || '';
|
||||
|
||||
const mail = mailerStatus();
|
||||
---
|
||||
<Admin title="Einstellungen" active="einstellungen" crumbs={[{ label: 'Einstellungen' }]}>
|
||||
<div class="s-stack">
|
||||
@@ -51,26 +75,62 @@ const currencies = ['EUR', 'CHF', 'USD', 'GBP'];
|
||||
<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 class="s-help">Versandzonen & länderabhängige Preise unter „Versand".</div>
|
||||
</div>
|
||||
<button class="s-btn s-btn-primary" type="submit" style="align-self:flex-start">Alle Einstellungen speichern</button>
|
||||
<button class="s-btn s-btn-primary" type="submit" style="align-self:flex-start">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 class="s-section-title" style="margin-bottom:12px">Zahlung</div>
|
||||
<p style="margin:0 0 10px"><span class={`s-badge ${pp.provider === 'demo' ? 'amber' : 'green'}`}>Aktiv: {providerLabel}</span> <span class="s-badge gray" style="margin-left:6px">{pp.source === 'auto' ? 'Auto-Wahl' : pp.source === 'setting' ? 'manuell' : 'ENV'}</span></p>
|
||||
<div class="s-field"><label class="s-label">Anbieter</label>
|
||||
<select class="s-select" name="payment_provider" form="payForm">
|
||||
<option value="" selected={providerSetting === ''}>Automatisch (nach Keys)</option>
|
||||
<option value="mollie" selected={providerSetting === 'mollie'}>Mollie</option>
|
||||
<option value="stripe" selected={providerSetting === 'stripe'}>Stripe</option>
|
||||
<option value="demo" selected={providerSetting === 'demo'}>Demo (ohne Zahlung)</option>
|
||||
</select>
|
||||
</div>
|
||||
<ul class="s-help" style="margin:8px 0 0;padding-left:16px;line-height:1.7">
|
||||
<li>Mollie-Key (MOLLIE_API_KEY): <b>{mollieSet ? 'gesetzt' : 'fehlt'}</b></li>
|
||||
<li>Stripe-Key (STRIPE_SECRET_KEY): <b>{stripeSet ? 'gesetzt' : 'fehlt'}</b></li>
|
||||
</ul>
|
||||
{pp.provider === 'demo' && <p class="s-help" style="margin-top:8px">Ohne gültigen Key läuft der Checkout im Demo-Fallback (Bestellung ohne echte Zahlung).</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 class="s-section-title" style="margin-bottom:12px">E-Mail-Versand</div>
|
||||
<p style="margin:0 0 8px"><span class={`s-badge ${mail.provider === 'log' ? 'amber' : (mail.configured ? 'green' : 'red')}`}>{mail.provider === 'log' ? 'Log-Fallback' : mail.provider} {mail.provider !== 'log' ? (mail.configured ? '· konfiguriert' : '· unvollständig') : ''}</span></p>
|
||||
<p class="s-help">Absender: <b>{mail.from}</b></p>
|
||||
<p class="s-help" style="margin-top:6px">Bestellbestätigungen werden bei bezahlter Bestellung versendet. Ohne Provider-ENV landet die Mail im <a href={base + '/einstellungen/email-log'}>E-Mail-Log</a>.</p>
|
||||
<p class="s-help" style="margin-top:6px">ENV: <b>MAIL_PROVIDER</b> (listmonk|smtp), <b>MAIL_FROM</b>, Listmonk-/SMTP-Variablen.</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 Session-Login; Initial-Owner aus <b>ADMIN_EMAIL</b> / <b>ADMIN_PASS</b>. Admin-Pfad über <b>ADMIN_PATH</b>. Nutzer & Rollen unter „Nutzer & Zugänge".</p>
|
||||
<p class="s-help">Datenbank: SQLite (<b>DB_PATH</b>). Admin-Zugang über Session-Login; Initial-Owner aus <b>ADMIN_EMAIL</b> / <b>ADMIN_PASS</b>. Admin-Pfad über <b>ADMIN_PATH</b>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="POST" id="payForm"><input type="hidden" name="_action" value="payment" /><button class="s-btn s-btn-primary" type="submit">Zahlungsanbieter speichern</button></form>
|
||||
|
||||
<div class="s-card s-card-pad">
|
||||
<div class="s-section-title" style="margin-bottom:6px;font-size:15px">Module (Feature-Flags)</div>
|
||||
<p class="s-help" style="margin:0 0 14px">Schalte einzelne Funktionen zentral an oder aus. Abgeschaltete Module verschwinden aus Storefront und Admin.</p>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="_action" value="features" />
|
||||
<div class="s-form-grid" style="grid-template-columns:1fr 1fr;gap:14px">
|
||||
{FEATURE_KEYS.map((k) => (
|
||||
<label class="s-card" style="display:flex;gap:12px;align-items:flex-start;padding:14px;cursor:pointer">
|
||||
<input type="checkbox" name={k} checked={feature(k)} style="margin-top:3px;width:18px;height:18px;accent-color:var(--accent)" />
|
||||
<span><b style="display:block">{FEATURE_LABELS[k][0]}</b><span class="s-help" style="margin:0">{FEATURE_LABELS[k][1]}</span></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button class="s-btn s-btn-primary" type="submit" style="margin-top:14px">Module speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Admin>
|
||||
|
||||
@@ -27,6 +27,10 @@ if (Astro.request.method === 'POST') {
|
||||
featured: f.get('featured') === 'on',
|
||||
sort: parseInt(String(f.get('sort') || '99')) || 99,
|
||||
desc: String(f.get('desc') || ''),
|
||||
mwst: parseInt(String(f.get('mwst') || '19')) || 19,
|
||||
base_amount: f.get('base_amount') === '' || f.get('base_amount') == null ? null : parseFloat(String(f.get('base_amount')).replace(',', '.')),
|
||||
base_unit: String(f.get('base_unit') || ''),
|
||||
base_price_per: String(f.get('base_price_per') || ''),
|
||||
};
|
||||
const _me = currentUser(Astro.request);
|
||||
if (isNew) { const newId = createProduct(data); recordAudit({ user: _me?.email, action: 'create', entity: 'product', entity_id: String(newId) }); return Astro.redirect(`${base}/produkte/${newId}?saved=1`); }
|
||||
@@ -37,7 +41,7 @@ const product = isNew ? null : getProductById(id);
|
||||
if (!isNew && !product) return Astro.redirect(base + '/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 p = product || { name: '', slug: '', shortName: '', priceCents: 0, category: '', sizes: ['One Size'], images: [], cardImage: '', badge: '', stock: '', material: '', features: [], featured: false, sort: 99, desc: '', mwst: 19, base_amount: null, base_unit: '', base_price_per: '' };
|
||||
const priceStr = product ? (product.priceCents / 100).toFixed(2).replace('.', ',') : '';
|
||||
---
|
||||
<Admin title={isNew ? 'Neues Produkt' : (product.shortName || product.name)} active="produkte" crumbs={[{ label: 'Produkte', href: base + '/produkte' }, { label: isNew ? 'Neu' : (product.shortName || product.name) }]}>
|
||||
@@ -77,6 +81,21 @@ const priceStr = product ? (product.priceCents / 100).toFixed(2).replace('.', ',
|
||||
</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">MwSt-Satz</label>
|
||||
<select class="s-select" name="mwst">
|
||||
<option value="19" selected={(p.mwst ?? 19) === 19}>19 % (Regelsatz)</option>
|
||||
<option value="7" selected={p.mwst === 7}>7 % (ermäßigt)</option>
|
||||
<option value="0" selected={p.mwst === 0}>0 % (steuerbefreit)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="s-field"><label class="s-label">Grundpreis (PAngV) — optional</label>
|
||||
<div class="s-form-grid" style="grid-template-columns:1fr 1fr 1fr;gap:8px">
|
||||
<input class="s-input" name="base_amount" value={p.base_amount ?? ''} placeholder="Menge z. B. 250" />
|
||||
<input class="s-input" name="base_unit" value={p.base_unit || ''} placeholder="Einheit z. B. g" />
|
||||
<input class="s-input" name="base_price_per" value={p.base_price_per || ''} placeholder="je z. B. kg" />
|
||||
</div>
|
||||
<div class="s-help">Beispiel: 250 g · je „kg" → zeigt „xx,xx €/kg". Unterstützt g/kg und ml/l-Umrechnung.</div>
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
import Admin from '../../../layouts/Admin.astro';
|
||||
import { adminBase, currentUser } from '../../../lib/auth.js';
|
||||
const base = adminBase();
|
||||
import { listShippingZones, createShippingZone, updateShippingZone, deleteShippingZone, formatPrice, recordAudit } from '../../../lib/store.js';
|
||||
|
||||
let flash = '';
|
||||
const _me = currentUser(Astro.request);
|
||||
if (Astro.request.method === 'POST') {
|
||||
const f = await Astro.request.formData();
|
||||
const action = String(f.get('_action') || '');
|
||||
const editId = f.get('id') ? Number(f.get('id')) : null;
|
||||
if (action === 'delete') {
|
||||
deleteShippingZone(editId); recordAudit({ user: _me?.email, action: 'delete', entity: 'shipping_zone', entity_id: String(editId) });
|
||||
flash = 'Zone gelöscht.';
|
||||
} else if (action === 'zone') {
|
||||
const data = {
|
||||
name: String(f.get('name') || ''),
|
||||
countries: String(f.get('countries') || ''),
|
||||
price_cents: Math.round(parseFloat(String(f.get('price') || '0').replace(',', '.')) * 100) || 0,
|
||||
free_over_cents: String(f.get('free_over') || '').trim() === '' ? null : Math.round(parseFloat(String(f.get('free_over')).replace(',', '.')) * 100),
|
||||
delivery_days: String(f.get('delivery_days') || ''),
|
||||
sort: parseInt(String(f.get('sort') || '99')) || 99,
|
||||
active: f.get('active') === 'on',
|
||||
};
|
||||
if (editId) { updateShippingZone(editId, data); recordAudit({ user: _me?.email, action: 'update', entity: 'shipping_zone', entity_id: String(editId) }); flash = 'Zone gespeichert.'; }
|
||||
else { const nid = createShippingZone(data); recordAudit({ user: _me?.email, action: 'create', entity: 'shipping_zone', entity_id: String(nid) }); flash = 'Zone angelegt.'; }
|
||||
}
|
||||
}
|
||||
|
||||
const zones = listShippingZones();
|
||||
const euro = (c) => (c == null ? '' : (c / 100).toFixed(2).replace('.', ','));
|
||||
---
|
||||
<Admin title="Versand" active="versandzonen" crumbs={[{ label: 'Versand' }]}>
|
||||
<div class="s-stack">
|
||||
{flash && <div class="s-flash">✓ {flash}</div>}
|
||||
|
||||
<div class="s-card">
|
||||
<div class="s-card-head">Versandzonen</div>
|
||||
<div class="s-table-wrap">
|
||||
<table class="s-table">
|
||||
<thead><tr><th>Zone</th><th>Länder</th><th class="num">Preis</th><th class="num">Gratis ab</th><th>Lieferzeit</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{zones.length === 0 ? (<tr><td colspan="7" class="s-empty">Keine Zonen</td></tr>) :
|
||||
zones.map((z) => (
|
||||
<tr>
|
||||
<td><b>{z.name}</b></td>
|
||||
<td class="s-muted">{z.countries}</td>
|
||||
<td class="num">{formatPrice(z.price_cents)}</td>
|
||||
<td class="num">{z.free_over_cents != null ? formatPrice(z.free_over_cents) : '—'}</td>
|
||||
<td class="s-muted">{z.delivery_days || '—'}</td>
|
||||
<td><span class={`s-badge ${z.active ? 'green' : 'gray'}`}>{z.active ? 'Aktiv' : 'Inaktiv'}</span></td>
|
||||
<td style="text-align:right">
|
||||
<button class="s-btn s-btn-sm" type="button" onclick={`editZone(${z.id})`}>Bearbeiten</button>
|
||||
<form method="POST" style="display:inline" onsubmit="return confirm('Zone löschen?')"><input type="hidden" name="_action" value="delete" /><input type="hidden" name="id" value={z.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" id="zoneFormTitle" style="margin-bottom:14px;font-size:15px">Neue Zone</div>
|
||||
<form method="POST" id="zoneForm">
|
||||
<input type="hidden" name="_action" value="zone" />
|
||||
<input type="hidden" name="id" id="zoneId" value="" />
|
||||
<div class="s-form-grid" style="grid-template-columns:1fr 1fr">
|
||||
<div class="s-field"><label class="s-label">Name</label><input class="s-input" name="name" id="zName" required placeholder="z. B. Deutschland" /></div>
|
||||
<div class="s-field"><label class="s-label">Länder (ISO, Komma; EU für alle EU)</label><input class="s-input" name="countries" id="zCountries" placeholder="DE oder AT,CH oder EU" /></div>
|
||||
<div class="s-field"><label class="s-label">Preis (€)</label><input class="s-input" name="price" id="zPrice" placeholder="4,90" /></div>
|
||||
<div class="s-field"><label class="s-label">Gratis ab (€, optional)</label><input class="s-input" name="free_over" id="zFree" placeholder="49,00" /></div>
|
||||
<div class="s-field"><label class="s-label">Lieferzeit (Tage)</label><input class="s-input" name="delivery_days" id="zDays" placeholder="2–4" /></div>
|
||||
<div class="s-field"><label class="s-label">Reihenfolge</label><input class="s-input" name="sort" id="zSort" type="number" value="99" /></div>
|
||||
</div>
|
||||
<label style="display:flex;gap:8px;align-items:center;margin:8px 0 14px"><input type="checkbox" name="active" id="zActive" checked style="width:18px;height:18px;accent-color:var(--accent)" /> Aktiv</label>
|
||||
<button class="s-btn s-btn-primary" type="submit">Zone speichern</button>
|
||||
<button class="s-btn" type="button" onclick="resetZone()" style="margin-left:8px">Neu</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline define:vars={{ zones, euro: '' }} set:html={`window.__ZONES__=${JSON.stringify(zones)};`}></script>
|
||||
<script is:inline>
|
||||
function eu(c){ return c==null?'':(c/100).toFixed(2).replace('.',','); }
|
||||
function editZone(id){
|
||||
var z=(window.__ZONES__||[]).find(function(x){return x.id===id;}); if(!z) return;
|
||||
document.getElementById('zoneId').value=z.id;
|
||||
document.getElementById('zName').value=z.name||'';
|
||||
document.getElementById('zCountries').value=z.countries||'';
|
||||
document.getElementById('zPrice').value=eu(z.price_cents);
|
||||
document.getElementById('zFree').value=z.free_over_cents!=null?eu(z.free_over_cents):'';
|
||||
document.getElementById('zDays').value=z.delivery_days||'';
|
||||
document.getElementById('zSort').value=z.sort||99;
|
||||
document.getElementById('zActive').checked=!!z.active;
|
||||
document.getElementById('zoneFormTitle').textContent='Zone bearbeiten';
|
||||
document.getElementById('zoneForm').scrollIntoView({behavior:'smooth'});
|
||||
}
|
||||
function resetZone(){
|
||||
document.getElementById('zoneForm').reset();
|
||||
document.getElementById('zoneId').value='';
|
||||
document.getElementById('zoneFormTitle').textContent='Neue Zone';
|
||||
}
|
||||
</script>
|
||||
</Admin>
|
||||
Reference in New Issue
Block a user