50dfca59e1
P1 Medien: eigener Admin-Bereich /admin/medien (Grid, Mehrfach-Upload, Drag&Drop, Alt-Text, URL kopieren, Loeschen). Upload konvertiert JPG/PNG via sharp zu WebP (Qualitaet 82, max 2000px), Original wird verworfen; WebP/SVG/GIF/AVIF unveraendert; Konvertierungsfehler -> Original behalten statt 500. media um alt/width/height erweitert. Wiederverwendbarer Medien-Picker (public/media-picker.js) ersetzt den URL-Prompt im Block-Editor, Produkt-Editor (Karte/Galerie/Varianten-Bild), Slides und Popups. JSON-Quelle /api/admin/media (session-gesichert). P2 Varianten: products.options_json + Tabelle product_variants. Produkt-Editor mit Options-Definition + Matrix-Generator (Preis-Override/Bestand/SKU/Bild/aktiv je Variante). PDP-Selektoren -> Variante; Cart/Checkout tragen sku+Options, Order-Item bekommt sku/variant, Variantenpreis serverseitig verifiziert. Produkte ohne Optionen unveraendert. P3 Litestream: Binary im Dockerfile, docker-entrypoint.sh (Restore+replicate nur bei LITESTREAM_REPLICA_URL, sonst reiner Node-Start), litestream.yml, Backup-Status unter Einstellungen, README + .env.example. P4 Analytics: Bestseller, Top-Suchbegriffe, Umsatz/Quelle, Umsatz-Zeitreihe, AOV, Wiederkaufrate, Lager-Warnungen. Neue Dep sharp. +19 Unit-Tests (49 gesamt gruen), Build + Smoke (P1-P4) gruen.
169 lines
11 KiB
Plaintext
169 lines
11 KiB
Plaintext
---
|
|
import Admin from '../../../layouts/Admin.astro';
|
|
import { adminBase } from '../../../lib/auth.js';
|
|
const base = adminBase();
|
|
import { getSettings, setSetting, resolvePaymentProvider, FEATURE_KEYS, feature, abandonedCartStats, countPendingReviews, backupStatus } 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, Bestellhistorie & Adressbuch für Kund:innen; Adresse im Checkout vorbefüllt'],
|
|
feature_reviews: ['Bewertungen', 'Sterne-Bewertungen auf Produktseiten mit Moderation (Freigabe im Admin)'],
|
|
feature_wishlist: ['Merkliste', 'Herz-Button auf Produktkarten & Detailseite; Merkliste unter /merkliste (clientseitig)'],
|
|
feature_abandoned_cart: ['Warenkorb-Erinnerung', 'Begonnene Checkouts speichern & per Mail erinnern (Cron /api/cron/abandoned)'],
|
|
feature_search: ['Suche', 'Volltextsuche im Storefront-Header mit Ergebnisseite /suche'],
|
|
};
|
|
|
|
let flash = '';
|
|
if (Astro.request.method === 'POST') {
|
|
const f = await Astro.request.formData();
|
|
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 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();
|
|
const acStats = abandonedCartStats();
|
|
const pendingReviews = countPendingReviews();
|
|
const cronToken = (process.env.CRON_TOKEN || '').trim();
|
|
const backup = backupStatus();
|
|
---
|
|
<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 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">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</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">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>.</p>
|
|
</div>
|
|
|
|
{feature('feature_abandoned_cart') && (
|
|
<div class="s-card s-card-pad">
|
|
<div class="s-section-title" style="margin-bottom:12px">Warenkorb-Erinnerung</div>
|
|
<p class="s-help" style="margin-bottom:6px">Gespeicherte Warenkörbe: <b>{acStats.total}</b> · offen <b>{acStats.open}</b> · erinnert <b>{acStats.reminded}</b> · wiederhergestellt <b>{acStats.recovered}</b></p>
|
|
<p class="s-help">Versand-Trigger: <b>POST /api/cron/abandoned</b> (Header <code>Authorization: Bearer <CRON_TOKEN></code> oder <code>?token=</code>). {cronToken ? 'CRON_TOKEN gesetzt.' : 'CRON_TOKEN noch nicht gesetzt — Endpoint bleibt gesperrt.'}</p>
|
|
</div>
|
|
)}
|
|
|
|
{feature('feature_reviews') && pendingReviews > 0 && (
|
|
<div class="s-card s-card-pad">
|
|
<div class="s-section-title" style="margin-bottom:8px">Bewertungen</div>
|
|
<p class="s-help"><b>{pendingReviews}</b> Bewertung(en) warten auf Freigabe — <a href={base + '/bewertungen'}>jetzt prüfen</a>.</p>
|
|
</div>
|
|
)}
|
|
|
|
<div class="s-card s-card-pad">
|
|
<div class="s-section-title" style="margin-bottom:8px">Backup (Litestream)</div>
|
|
{backup.configured ? (
|
|
<>
|
|
<p class="s-help" style="margin-bottom:6px"><span class="s-badge green">aktiv</span> Streaming-Backup nach <b>{backup.target}</b>{backup.endpoint ? ` (Endpoint ${backup.endpoint})` : ''}.</p>
|
|
{!backup.fullCredentials && <p class="s-help" style="color:#b3261e">Achtung: Zugangsdaten unvollständig — LITESTREAM_ACCESS_KEY_ID / LITESTREAM_SECRET_ACCESS_KEY prüfen.</p>}
|
|
<p class="s-help">DB: <code>{backup.dbPath}</code>. Restore: <code>litestream restore -if-replica-exists {backup.dbPath}</code></p>
|
|
</>
|
|
) : (
|
|
<p class="s-help"><span class="s-badge gray">inaktiv</span> Kein Replica konfiguriert. Setze <b>LITESTREAM_REPLICA_URL</b> (S3/Backblaze B2) plus Zugangsdaten als ENV, um stündliche Streaming-Backups zu aktivieren (siehe README).</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>
|