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
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.astro
.git
data
*.log
.env
+11
View File
@@ -0,0 +1,11 @@
# hd-commerce — Umgebungsvariablen
# Pfad zur SQLite-Datenbank (wird automatisch angelegt)
DB_PATH=./data/hdc.db
# Admin-Zugang (Basic-Auth fuer /admin)
ADMIN_USER=admin
ADMIN_PASS=admin
# Stripe (optional). Ohne echte Keys laeuft der Demo-Checkout-Fallback.
STRIPE_PUBLIC_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.astro
data
*.log
.DS_Store
.env
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ ca-certificates wget && rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm install --no-audit --no-fund
COPY . .
RUN npm run build
ENV HOST=0.0.0.0 PORT=4321 NODE_ENV=production DB_PATH=/data/hdc.db
RUN mkdir -p /data
EXPOSE 4321
HEALTHCHECK --interval=15s --timeout=5s --start-period=25s --retries=5 CMD wget -qO- http://127.0.0.1:4321/ >/dev/null 2>&1 || exit 1
CMD ["node","./dist/server/entry.mjs"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Heidrich Digital
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+28
View File
@@ -0,0 +1,28 @@
# Spiegelung nach GitHub
Dieses Repo lebt primär auf Gitea (`git.heidrich-digital.de/till/hd-commerce`). So spiegelst du es nach GitHub.
## Option A — Gitea Push-Mirror (empfohlen)
1. In GitHub ein leeres Repo `hd-commerce` anlegen (ohne README/License).
2. In GitHub einen **Personal Access Token** (Scope `repo`) erstellen.
3. In Gitea: **Repo → Einstellungen → Mirror-Einstellungen → Push-Mirror hinzufügen**
- Git-Remote-URL: `https://github.com/<user>/hd-commerce.git`
- Authorization: GitHub-Username + PAT als Passwort
- Intervall z. B. `8h`, „Beim Push synchronisieren" aktivieren.
4. „Jetzt synchronisieren" — ab jetzt pusht Gitea automatisch nach GitHub.
## Option B — manuell per zweitem Remote
```bash
git remote add github https://github.com/<user>/hd-commerce.git
git push github main
```
Für künftige Pushes auf beide Remotes:
```bash
git remote set-url --add --push origin https://git.heidrich-digital.de/till/hd-commerce.git
git remote set-url --add --push origin https://github.com/<user>/hd-commerce.git
git push origin main # geht jetzt an beide
```
+59
View File
@@ -0,0 +1,59 @@
# hd-commerce
**hd-commerce** ist ein eigenständiges, brand-neutrales E-Commerce-Backend von Heidrich Digital: eine wiederverwendbare Astro-SSR-Anwendung mit Commerce-Engine (SQLite), Shopify-artigem Admin, JSON-API und einem schlanken, neutralen Demo-Storefront.
Die mitgelieferte Demo-Instanz heißt **„Brittas Nähkiste"** (Kurzwaren/Nähbedarf) und dient nur als Beispiel. Name, Akzentfarbe, Texte und Logo-Wortmarke sind über die Einstellungen frei anpassbar — derselbe Code läuft für beliebige Shops.
## Features
- **Storefront** (hell, editorial, neutral): Startseite mit Announcement-Bar, Slider, Kategorien, Featured-Produkten und Newsletter; Shop-Katalog mit Kategorie-Filter; Produktdetailseiten; Warenkorb (localStorage); Checkout; Rechts-/Inhaltsseiten aus der DB.
- **Admin** (Shopify-Stil): Dashboard mit KPIs & Funnel, Bestellungen mit Status-Workflow, Produkt-Editor (CRUD), Kunden, eigene Analytics (First-Party, Chart.js), Marketing (Popups & Announcement-Bar), Inhalte (Seiten, Slider, Medien-Upload) und Einstellungen.
- **Engine**: synchron via `better-sqlite3` (WAL), automatisches Seeding beim ersten Start.
- **API**: `/api/checkout` (Stripe Hosted Checkout oder Demo-Fallback), `/api/track`, `/api/subscribe`, `/api/upload`, `/uploads/[file]`.
- **First-Party-Analytics**: eigene `events`-Tabelle, kein externer Dienst, keine personenbezogenen Rohdaten (Session = täglich rollender Hash aus IP+UA+Tag).
- **Branding konfigurierbar**: Shop-Name, Akzentfarbe, Währung u. a. in einer `settings`-Tabelle; Akzentfarbe wird als CSS-Variable in Storefront **und** Admin injiziert.
- **Self-hosted Fonts** (Fraunces + Public Sans), kein Google-CDN.
## Umgebungsvariablen (ENV)
| Variable | Beschreibung | Default |
|---|---|---|
| `DB_PATH` | Pfad zur SQLite-Datenbank (wird angelegt) | `./data/hdc.db` |
| `ADMIN_USER` | Basic-Auth-Benutzer für `/admin` | `admin` |
| `ADMIN_PASS` | Basic-Auth-Passwort für `/admin` | `admin` |
| `STRIPE_PUBLIC_KEY` | Stripe Publishable Key (optional) | |
| `STRIPE_SECRET_KEY` | Stripe Secret Key (`sk_test_…`/`sk_live_…`). Ohne echten Key läuft der Demo-Checkout. | |
Siehe `.env.example`.
## Lokal starten
```bash
npm install
npm run dev # Entwicklungsserver auf http://localhost:4321
# oder produktiv:
npm run build
node ./dist/server/entry.mjs
```
Storefront: `/` · Admin: `/admin` (Standard `admin` / `admin`).
## Docker / Coolify
```bash
docker build -t hd-commerce .
docker run -p 4321:4321 -v hdc-data:/data \
-e ADMIN_USER=admin -e ADMIN_PASS=geheim hd-commerce
```
Das `Dockerfile` (node:22-slim) baut `better-sqlite3` nativ, legt `/data` an und setzt `DB_PATH=/data/hdc.db`. Auf Coolify ein persistentes Volume auf `/data` mounten, damit Datenbank und Uploads erhalten bleiben. HEALTHCHECK prüft `/`.
## Datenmodell
`settings`, `products`, `orders`, `customers`, `slides`, `pages`, `popups`, `subscribers`, `events`, `media` — alles seed-bar und im Admin pflegbar.
---
> **Hinweis:** „Brittas Nähkiste" ist nur die mitgelieferte Demo-Instanz. Brand (Name, Farben, Texte) ist über **Admin → Einstellungen** anpassbar.
Lizenz: MIT (siehe `LICENSE`).
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
server: { host: '0.0.0.0', port: 4321 },
security: { checkOrigin: false },
});
+20
View File
@@ -0,0 +1,20 @@
{
"name": "hd-commerce",
"type": "module",
"version": "1.0.0",
"private": true,
"description": "hd-commerce — neutrales SQLite-Commerce-Backend (Admin + API + Demo-Storefront)",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"start": "node ./dist/server/entry.mjs"
},
"dependencies": {
"astro": "^5.6.0",
"@astrojs/node": "^9.1.3",
"better-sqlite3": "^11.8.1",
"stripe": "^17.5.0",
"@fontsource-variable/fraunces": "^5.1.0",
"@fontsource-variable/public-sans": "^5.1.0"
}
}
+75
View File
@@ -0,0 +1,75 @@
/* hd-commerce — Popup-Engine (vanilla). Frequenz-Cap via localStorage. */
(function () {
var root = document.getElementById('popupRoot');
if (!root) return;
var popups;
try { popups = JSON.parse(root.getAttribute('data-popups') || '[]'); } catch (e) { return; }
if (!popups || !popups.length) return;
function seen(p) {
var k = 'hdc_popup_' + p.id;
var v = localStorage.getItem(k);
if (!v) return false;
if (p.freq === 'always') return false;
if (p.freq === 'session') return sessionStorage.getItem('hdc_ps_' + p.id) === '1';
if (p.freq === 'days7') { return (Date.now() - parseInt(v, 10)) < 7 * 864e5; }
return true;
}
function mark(p) {
localStorage.setItem('hdc_popup_' + p.id, String(Date.now()));
if (p.freq === 'session') sessionStorage.setItem('hdc_ps_' + p.id, '1');
}
function build(p) {
var ov = document.createElement('div');
ov.className = 'hdc-popup-overlay';
var isNl = p.type === 'newsletter';
ov.innerHTML =
'<div class="hdc-popup">' +
'<button class="px" aria-label="Schließen">&times;</button>' +
(p.image ? '<img src="' + p.image + '" alt="" style="border-radius:10px;margin-bottom:16px;max-height:160px;width:100%;object-fit:cover">' : '') +
'<h3>' + (p.headline || '') + '</h3>' +
'<p>' + (p.body || '') + '</p>' +
(isNl
? '<form class="nl-form"><input type="email" required placeholder="deine@email.de"><button class="btn btn-primary btn-block" type="submit">' + (p.cta_text || 'Anmelden') + '</button><div class="nl-msg"></div></form>'
: (p.cta_url ? '<a class="btn btn-primary btn-lg" href="' + p.cta_url + '">' + (p.cta_text || 'Mehr') + '</a>' : '')) +
'</div>';
document.body.appendChild(ov);
requestAnimationFrame(function () { ov.classList.add('show'); });
function close() { ov.classList.remove('show'); mark(p); setTimeout(function () { ov.remove(); }, 320); }
ov.querySelector('.px').addEventListener('click', close);
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
var form = ov.querySelector('form');
if (form) {
form.addEventListener('submit', function (e) {
e.preventDefault();
var email = form.querySelector('input').value;
var msg = form.querySelector('.nl-msg');
fetch('/api/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email, source: 'popup' }) })
.then(function (r) { return r.json(); })
.then(function () { msg.textContent = 'Danke! Schau in dein Postfach.'; setTimeout(close, 1600); })
.catch(function () { msg.textContent = 'Bitte später erneut versuchen.'; });
});
}
}
function arm(p) {
if (seen(p)) return;
var trig = p.trigger || 'delay';
var val = parseInt(p.trigger_value, 10) || 0;
if (trig === 'delay') { setTimeout(function () { build(p); }, Math.max(0, val) * 1000); }
else if (trig === 'scroll') {
var fn = function () {
var sc = (window.scrollY + window.innerHeight) / document.body.scrollHeight * 100;
if (sc >= (val || 50)) { window.removeEventListener('scroll', fn); build(p); }
};
window.addEventListener('scroll', fn, { passive: true });
} else if (trig === 'exit') {
var fired = false;
document.addEventListener('mouseout', function (e) {
if (!fired && e.clientY <= 0 && !e.relatedTarget) { fired = true; build(p); }
});
setTimeout(function () { if (!fired) { fired = true; build(p); } }, 25000); // mobile-fallback
}
}
popups.forEach(arm);
})();
+62
View File
@@ -0,0 +1,62 @@
/* hd-commerce — Storefront Cart (vanilla, localStorage) */
(function () {
var KEY = 'hdc_cart';
function read() { try { return JSON.parse(localStorage.getItem(KEY) || '[]'); } catch (e) { return []; } }
function write(c) { localStorage.setItem(KEY, JSON.stringify(c)); updateBadge(); }
function count() { return read().reduce(function (s, i) { return s + (i.qty || 1); }, 0); }
function updateBadge() {
var b = document.getElementById('cartBadge');
if (!b) return;
var n = count();
b.textContent = n;
b.classList.toggle('show', n > 0);
}
function track(type, value, meta) {
try {
fetch('/api/track', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: type, path: location.pathname, value_cents: value || 0, meta: meta || {} }) });
} catch (e) {}
}
function add(item) {
var c = read();
var ex = c.find(function (i) { return i.slug === item.slug && i.size === item.size; });
if (ex) ex.qty += item.qty || 1; else c.push(item);
write(c);
track('add_to_cart', (item.priceCents || 0) * (item.qty || 1), { slug: item.slug });
toast(item.name + ' wurde hinzugefügt');
}
function toast(msg) {
var t = document.createElement('div');
t.textContent = msg;
t.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--ink);color:#fff;padding:12px 22px;border-radius:999px;font-size:14px;font-weight:600;z-index:200;box-shadow:0 8px 30px rgba(0,0,0,.2);transition:.3s;opacity:0';
document.body.appendChild(t);
requestAnimationFrame(function () { t.style.opacity = '1'; });
setTimeout(function () { t.style.opacity = '0'; setTimeout(function () { t.remove(); }, 320); }, 2000);
}
window.HDC = {
read: read, write: write, count: count, add: add, track: track,
remove: function (idx) { var c = read(); c.splice(idx, 1); write(c); },
setQty: function (idx, q) { var c = read(); if (c[idx]) { c[idx].qty = Math.max(1, q); write(c); } },
clear: function () { write([]); },
subtotal: function () { return read().reduce(function (s, i) { return s + (i.priceCents || 0) * (i.qty || 1); }, 0); }
};
document.addEventListener('DOMContentLoaded', function () {
updateBadge();
document.querySelectorAll('[data-add-to-cart]').forEach(function (btn) {
btn.addEventListener('click', function () {
var p = JSON.parse(btn.getAttribute('data-product') || '{}');
var sizeSel = document.querySelector('.size-chip.active');
var size = sizeSel ? sizeSel.getAttribute('data-size') : (p.sizes && p.sizes[0]) || 'One Size';
add({ slug: p.slug, name: p.name, size: size, priceCents: p.priceCents, image: p.image, qty: 1 });
});
});
document.querySelectorAll('.size-chip').forEach(function (chip) {
chip.addEventListener('click', function () {
document.querySelectorAll('.size-chip').forEach(function (c) { c.classList.remove('active'); });
chip.classList.add('active');
});
});
var pv = document.getElementById('pdpData');
if (pv) track('product_view', 0, { slug: pv.getAttribute('data-slug') });
});
})();
+16
View File
@@ -0,0 +1,16 @@
(function () {
var track = document.getElementById('hdcSlides');
if (!track) return;
var slides = track.children, n = slides.length, i = 0;
var dots = document.querySelectorAll('#hdcDots button');
function go(x) {
i = (x + n) % n;
track.style.transform = 'translateX(-' + (i * 100) + '%)';
dots.forEach(function (d, k) { d.classList.toggle('active', k === i); });
}
var prev = document.getElementById('hdcPrev'), next = document.getElementById('hdcNext');
if (prev) prev.addEventListener('click', function () { go(i - 1); });
if (next) next.addEventListener('click', function () { go(i + 1); });
dots.forEach(function (d) { d.addEventListener('click', function () { go(+d.getAttribute('data-idx')); }); });
if (n > 1) setInterval(function () { go(i + 1); }, 6000);
})();
+73
View File
@@ -0,0 +1,73 @@
---
import '@fontsource-variable/public-sans';
import '../styles/admin.css';
import { getSettings } from '../lib/store.js';
export interface Props { title: string; active?: string; crumbs?: { label: string; href?: string }[]; }
const { title, active = '', crumbs = [] } = Astro.props;
const settings = getSettings();
const shopName = settings.shop_name || 'hd-commerce';
const accent = settings.brand_accent || '#b8566a';
const accentDark = settings.brand_accent_dark || '#8d3f50';
const initial = (shopName.trim()[0] || 'H').toUpperCase();
const nav = [
{ key:'dashboard', label:'Dashboard', href:'/admin', icon:'M3 13h8V3H3v10Zm0 8h8v-6H3v6Zm10 0h8V11h-8v10Zm0-18v6h8V3h-8Z' },
{ key:'bestellungen', label:'Bestellungen', href:'/admin/bestellungen', icon:'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4H6Zm.5 2h11l1.5 2H5l1.5-2ZM5 8h14v12H5V8Zm4 2a3 3 0 0 0 6 0' },
{ key:'produkte', label:'Produkte', href:'/admin/produkte', icon:'M20.5 7.3 12 2 3.5 7.3 12 12.6l8.5-5.3ZM3 9v8l8 5v-8L3 9Zm10 13 8-5V9l-8 5v8Z' },
{ key:'kunden', label:'Kunden', href:'/admin/kunden', icon:'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0 2c-4 0-8 2-8 5v1h16v-1c0-3-4-5-8-5Z' },
{ key:'analytics', label:'Analytics', href:'/admin/analytics', icon:'M4 20V10m6 10V4m6 16v-7m4 7H2' },
{ key:'marketing', label:'Marketing', href:'/admin/marketing', icon:'M3 11l18-5v12l-7-2v4l-4-1v-3L3 11Z' },
{ key:'inhalte', label:'Inhalte', href:'/admin/inhalte', icon:'M4 4h16v4H4V4Zm0 6h10v10H4V10Zm12 0h4v10h-4V10Z' },
{ key:'einstellungen', label:'Einstellungen', href:'/admin/einstellungen', icon:'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm9 4-2 .5.4 2-1.7 1.3-1.7-1.2-1.8.8-.3 2H10l-.3-2-1.8-.8-1.7 1.2L4.5 14.5 5 12.5 3 12l.5-2 2-.5-.4-2L6.8 6.2l1.7 1.2 1.8-.8.3-2h2.8l.3 2 1.8.8 1.7-1.2 1.7 1.3-.4 2 2 .5-.5 2Z' },
];
---
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>{title} · {shopName} Admin</title>
<style is:inline set:html={`:root{--accent:${accent};--accent-dark:${accentDark};}`}></style>
</head>
<body class="admin-body">
<div class="admin-shell">
<aside class="s-side">
<div class="s-brand">
<div class="s-brand-logo">{initial}</div>
<div><div class="s-brand-name">{shopName}</div><div class="s-brand-sub">Admin · hd-commerce</div></div>
</div>
<nav class="s-nav">
{nav.map((n) => (
<a href={n.href} class={active === n.key ? 'active' : ''}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={n.icon}/></svg>
{n.label}
</a>
))}
<div class="s-nav-sec">Vertriebskanal</div>
<a href="/" target="_blank">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>
Online-Shop ↗
</a>
</nav>
<div class="s-side-foot">{shopName} · <a href="/">Shop ansehen</a></div>
</aside>
<div class="s-main">
<header class="s-topbar">
<div>
<div class="s-crumbs">
<a href="/admin">Admin</a>
{crumbs.map((c) => (<><span>/</span>{c.href ? <a href={c.href}>{c.label}</a> : <span>{c.label}</span>}</>))}
</div>
<div class="s-title">{title}</div>
</div>
<div class="s-actions"><slot name="actions" /></div>
</header>
<div class="s-content"><slot /></div>
</div>
</div>
</body>
</html>
+101
View File
@@ -0,0 +1,101 @@
---
import '@fontsource-variable/fraunces';
import '@fontsource-variable/public-sans';
import '../styles/global.css';
import { getSettings, listLegalPages, listCategories, popupsForPath } from '../lib/store.js';
export interface Props { title?: string; description?: string; }
const { title, description } = Astro.props;
const settings = getSettings();
const shopName = settings.shop_name || 'hd-commerce';
const accent = settings.brand_accent || '#b8566a';
const accentDark = settings.brand_accent_dark || '#8d3f50';
const tagline = settings.shop_tagline || '';
const announceActive = settings.announcement_active === '1';
const announceText = settings.announcement_text || '';
const announceLink = settings.announcement_link || '/shop';
const legalPages = listLegalPages();
const categories = listCategories();
const pageTitle = title ? `${title} · ${shopName}` : shopName;
const desc = description || tagline || `${shopName} — Online-Shop`;
const path = new URL(Astro.request.url).pathname;
const popups = popupsForPath(path);
---
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={desc} />
<title>{pageTitle}</title>
<style is:inline set:html={`:root{--accent:${accent};--accent-dark:${accentDark};}`}></style>
</head>
<body>
{announceActive && announceText && (
<div class="announce"><a href={announceLink}>{announceText}</a></div>
)}
<header class="site-head">
<div class="wrap site-head-row">
<a class="brand-mark" href="/"><span>{shopName}</span><span class="uscore"></span></a>
<nav class="main-nav">
<a href="/">Start</a>
<a href="/shop">Shop</a>
{categories.slice(0,3).map((c) => (<a href={`/shop?cat=${encodeURIComponent(c)}`}>{c}</a>))}
<a href="/seite/ueber-uns">Über uns</a>
</nav>
<div class="head-actions">
<a class="icon-btn" href="/shop" aria-label="Suche">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
</a>
<a class="icon-btn" href="/warenkorb" aria-label="Warenkorb">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>
<span class="cart-badge" id="cartBadge">0</span>
</a>
</div>
</div>
</header>
<main>
<slot />
</main>
<footer class="site-foot">
<div class="wrap">
<div class="foot-grid">
<div class="foot-brand">
<a class="brand-mark" href="/"><span>{shopName}</span><span class="uscore"></span></a>
<p>{tagline || `${shopName} — sorgfältig ausgewählte Produkte, schneller Versand.`}</p>
</div>
<div class="foot-col">
<h4>Shop</h4>
<a href="/shop">Alle Produkte</a>
{categories.map((c) => (<a href={`/shop?cat=${encodeURIComponent(c)}`}>{c}</a>))}
</div>
<div class="foot-col">
<h4>Service</h4>
<a href="/seite/ueber-uns">Über uns</a>
<a href="/warenkorb">Warenkorb</a>
{settings.shop_email && (<a href={`mailto:${settings.shop_email}`}>Kontakt</a>)}
</div>
<div class="foot-col">
<h4>Rechtliches</h4>
{legalPages.map((p) => (<a href={`/seite/${p.slug}`}>{p.title}</a>))}
</div>
</div>
<div class="foot-bottom">
<span>© {new Date().getFullYear()} {shopName}. Alle Rechte vorbehalten.</span>
<span>Powered by hd-commerce</span>
</div>
</div>
</footer>
<div id="popupRoot" data-popups={JSON.stringify(popups)}></div>
<script src="/shop.js" is:inline></script>
<script src="/popups.js" is:inline></script>
</body>
</html>
+150
View File
@@ -0,0 +1,150 @@
// Demo-Seed "Brittas Naehkiste" — neutrale Kurzwaren/Naehbedarf-Daten.
// Diese Demo-Instanz ist nur ein Beispiel; Brand/Inhalte sind ueber das Admin anpassbar.
const img = (slug) => `https://picsum.photos/seed/${slug}/900/1100`;
export const SEED_SETTINGS = {
shop_name: 'Brittas Nähkiste',
brand_accent: '#b8566a',
brand_accent_dark: '#8d3f50',
currency: 'EUR',
free_shipping_cents: '4900',
announcement_text: 'Versandkostenfrei ab 49 €',
announcement_active: '1',
announcement_link: '/shop',
shop_tagline: 'Stoffe, Garne & alles fürs Nähen',
shop_email: 'hallo@brittas-naehkiste.de',
};
export const SEED_PRODUCTS = [
{
slug: 'baumwollstoff-bluetenwiese', name: 'Baumwollstoff „Blütenwiese" (Meterware)', shortName: 'Baumwollstoff Blütenwiese',
priceCents: 1290, category: 'Stoffe', sizes: ['0,5 m', '1 m', '2 m', '3 m'],
images: [img('bluetenwiese'), img('bluetenwiese2')], cardImage: img('bluetenwiese'),
badge: 'Beliebt', stock: 120, material: '100 % Baumwolle, 145 cm breit',
features: ['Öko-Tex Standard 100', 'Pflegeleicht, 40°C waschbar', 'Ideal für Kleider & Deko'],
featured: true, sort: 1, desc: 'Leichter Baumwollstoff mit zartem Blütenmuster — perfekt für sommerliche Kleider, Blusen und Heimtextilien. Preis pro laufendem Meter.'
},
{
slug: 'jersey-marine', name: 'Jersey „Marine" (Meterware)', shortName: 'Jersey Marine',
priceCents: 1590, category: 'Stoffe', sizes: ['0,5 m', '1 m', '2 m'],
images: [img('jerseymarine'), img('jerseymarine2')], cardImage: img('jerseymarine'),
badge: '', stock: 80, material: '95 % Baumwolle, 5 % Elasthan, 160 cm breit',
features: ['Elastisch & formstabil', 'Angenehm weicher Griff', 'Für Shirts & Kinderkleidung'],
featured: true, sort: 2, desc: 'Weicher, dehnbarer Baumwoll-Jersey in tiefem Marineblau. Vielseitig für Shirts, Hoodies und Babykleidung. Preis pro Meter.'
},
{
slug: 'naehgarn-set-12-farben', name: 'Nähgarn-Set 12 Farben', shortName: 'Nähgarn-Set 12',
priceCents: 990, category: 'Garne & Fäden', sizes: ['One Size'],
images: [img('naehgarn'), img('naehgarn2')], cardImage: img('naehgarn'),
badge: 'Set', stock: 60, material: '100 % Polyester, je 200 m',
features: ['12 aufeinander abgestimmte Farben', 'Reißfest & universell', 'Für Hand- und Maschinennähen'],
featured: true, sort: 3, desc: 'Praktisches Allround-Set mit zwölf Nähgarnen in harmonischen Farbtönen. Geeignet für nahezu alle Stoffe und Projekte.'
},
{
slug: 'knopf-sortiment-50', name: 'Knopf-Sortiment 50 Stück', shortName: 'Knopf-Sortiment 50',
priceCents: 690, category: 'Kurzwaren', sizes: ['One Size'],
images: [img('knoepfe'), img('knoepfe2')], cardImage: img('knoepfe'),
badge: '', stock: 45, material: 'Kunststoff & Holz, gemischt',
features: ['Verschiedene Größen & Farben', '2- und 4-Loch-Knöpfe', 'Praktische Sortierbox'],
featured: false, sort: 4, desc: 'Bunt gemischtes Knopf-Sortiment mit 50 Stück in unterschiedlichen Größen, Farben und Materialien — für jedes Nähprojekt etwas dabei.'
},
{
slug: 'reissverschluss-set', name: 'Reißverschluss-Set (10 Stück)', shortName: 'Reißverschluss-Set',
priceCents: 1190, category: 'Kurzwaren', sizes: ['20 cm', '30 cm', '40 cm'],
images: [img('reissverschluss'), img('reissverschluss2')], cardImage: img('reissverschluss'),
badge: '', stock: 70, material: 'Kunststoffspirale, Metallschieber',
features: ['10 Stück gemischte Farben', 'Teilbar & nicht teilbar', 'Robuster Schieber'],
featured: false, sort: 5, desc: 'Sortiment aus zehn Reißverschlüssen in verschiedenen Farben und Längen — passend für Taschen, Jacken und Kissenbezüge.'
},
{
slug: 'schnittmuster-sommerkleid-lotta', name: 'Schnittmuster „Sommerkleid Lotta"', shortName: 'Schnittmuster Lotta',
priceCents: 890, category: 'Schnittmuster', sizes: ['Gr. 3448'],
images: [img('schnittmuster'), img('schnittmuster2')], cardImage: img('schnittmuster'),
badge: 'Neu', stock: 999, material: 'Papier-Schnittmuster + Anleitung',
features: ['Größen 34 bis 48', 'Bebilderte Schritt-für-Schritt-Anleitung', 'Anfängerfreundlich'],
featured: true, sort: 6, desc: 'Leichtes A-Linien-Sommerkleid mit Taschen und Knopfleiste. Inklusive ausführlicher Nähanleitung in mehreren Größen.'
},
{
slug: 'stoffschere-profi', name: 'Stoffschere Profi 24 cm', shortName: 'Stoffschere Profi',
priceCents: 2490, category: 'Werkzeug', sizes: ['One Size'],
images: [img('stoffschere'), img('stoffschere2')], cardImage: img('stoffschere'),
badge: 'Profi', stock: 30, material: 'Rostfreier Edelstahl',
features: ['Geschliffene Präzisionsklinge', 'Ergonomischer Griff', 'Für Stoff, Leder & Filz'],
featured: false, sort: 7, desc: 'Hochwertige Schneiderschere mit langer Edelstahlklinge für saubere, mühelose Schnitte durch dicke und dünne Stoffe.'
},
{
slug: 'massband-stecknadel-set', name: 'Maßband + Stecknadel-Set', shortName: 'Maßband & Nadeln',
priceCents: 590, category: 'Werkzeug', sizes: ['One Size'],
images: [img('massband'), img('massband2')], cardImage: img('massband'),
badge: '', stock: 90, material: 'Maßband 150 cm, 100 Stecknadeln',
features: ['Flexibles Rollmaßband', '100 Stecknadeln mit Glaskopf', 'Inkl. Nadelkissen'],
featured: false, sort: 8, desc: 'Praktisches Starter-Set mit flexiblem Maßband und 100 Glaskopf-Stecknadeln inklusive Nadelkissen — die Basis jeder Nähausstattung.'
},
];
export const SEED_CUSTOMERS = [
{ name: 'Anja Vogel', email: 'anja.vogel@example.de', city: 'Hamburg' },
{ name: 'Beate Lindner', email: 'beate.lindner@example.de', city: 'Bremen' },
{ name: 'Carla Sommer', email: 'carla.sommer@example.de', city: 'Hannover' },
{ name: 'Doreen Krause', email: 'doreen.krause@example.de', city: 'Lüneburg' },
{ name: 'Elena Fischer', email: 'elena.fischer@example.de', city: 'Buxtehude' },
];
export function seedOrders() {
const day = (n) => { const d = new Date(); d.setDate(d.getDate() - n); return d.toISOString(); };
return [
{ number: 'BNK-1001', email: 'anja.vogel@example.de', customer_name: 'Anja Vogel', status: 'fulfilled', total_cents: 3370,
items: [{ name: 'Baumwollstoff „Blütenwiese" (Meterware)', size: '2 m', qty: 2, priceCents: 1290 }, { name: 'Nähgarn-Set 12 Farben', size: 'One Size', qty: 1, priceCents: 990 }],
address: 'Anja Vogel, Lindenweg 4, 20095 Hamburg', created_at: day(18) },
{ number: 'BNK-1002', email: 'beate.lindner@example.de', customer_name: 'Beate Lindner', status: 'fulfilled', total_cents: 2480,
items: [{ name: 'Stoffschere Profi 24 cm', size: 'One Size', qty: 1, priceCents: 2490 }],
address: 'Beate Lindner, Marktstraße 11, 28199 Bremen', created_at: day(14) },
{ number: 'BNK-1003', email: 'carla.sommer@example.de', customer_name: 'Carla Sommer', status: 'pending', total_cents: 4170,
items: [{ name: 'Jersey „Marine" (Meterware)', size: '2 m', qty: 2, priceCents: 1590 }, { name: 'Knopf-Sortiment 50 Stück', size: 'One Size', qty: 1, priceCents: 690 }],
address: 'Carla Sommer, Am Bach 7, 30159 Hannover', created_at: day(9) },
{ number: 'BNK-1004', email: 'doreen.krause@example.de', customer_name: 'Doreen Krause', status: 'pending', total_cents: 1780,
items: [{ name: 'Schnittmuster „Sommerkleid Lotta"', size: 'Gr. 3448', qty: 2, priceCents: 890 }],
address: 'Doreen Krause, Gartenstraße 22, 21335 Lüneburg', created_at: day(6) },
{ number: 'BNK-1005', email: 'elena.fischer@example.de', customer_name: 'Elena Fischer', status: 'fulfilled', total_cents: 2670,
items: [{ name: 'Reißverschluss-Set (10 Stück)', size: '30 cm', qty: 1, priceCents: 1190 }, { name: 'Maßband + Stecknadel-Set', size: 'One Size', qty: 1, priceCents: 590 }, { name: 'Knopf-Sortiment 50 Stück', size: 'One Size', qty: 1, priceCents: 690 }],
address: 'Elena Fischer, Hauptstraße 3, 21614 Buxtehude', created_at: day(5) },
{ number: 'BNK-1006', email: 'anja.vogel@example.de', customer_name: 'Anja Vogel', status: 'cancelled', total_cents: 1290,
items: [{ name: 'Baumwollstoff „Blütenwiese" (Meterware)', size: '1 m', qty: 1, priceCents: 1290 }],
address: 'Anja Vogel, Lindenweg 4, 20095 Hamburg', created_at: day(4) },
{ number: 'BNK-1007', email: 'carla.sommer@example.de', customer_name: 'Carla Sommer', status: 'refunded', total_cents: 990,
items: [{ name: 'Nähgarn-Set 12 Farben', size: 'One Size', qty: 1, priceCents: 990 }],
address: 'Carla Sommer, Am Bach 7, 30159 Hannover', created_at: day(3) },
{ number: 'BNK-1008', email: 'beate.lindner@example.de', customer_name: 'Beate Lindner', status: 'pending', total_cents: 3180,
items: [{ name: 'Jersey „Marine" (Meterware)', size: '1 m', qty: 2, priceCents: 1590 }],
address: 'Beate Lindner, Marktstraße 11, 28199 Bremen', created_at: day(1) },
];
}
export const SEED_SLIDES = [
{ image: img('slide-neuheiten'), headline: 'Frische Stoffe für den Sommer', subline: 'Neue Baumwoll- und Jersey-Qualitäten — jetzt entdecken.', link: '/shop', sort: 1, active: 1 },
{ image: img('slide-aktion'), headline: 'Versandkostenfrei ab 49 €', subline: 'Jetzt das Nähkörbchen auffüllen und sparen.', link: '/shop', sort: 2, active: 1 },
{ image: img('slide-saison'), headline: 'Schnittmuster der Saison', subline: 'Vom Sommerkleid bis zum Lieblingsshirt.', link: '/produkt/schnittmuster-sommerkleid-lotta', sort: 3, active: 1 },
];
const legalBody = (title, extra) => `<h2>${title}</h2>\n<p><strong>Brittas Nähkiste</strong><br>Britta Mustermann<br>Musterstraße 3<br>21680 Musterort</p>\n<p>E-Mail: hallo@brittas-naehkiste.de<br>Telefon: 0 41 41 / 12 34 56</p>\n${extra}`;
export const SEED_PAGES = [
{ slug: 'ueber-uns', title: 'Über uns', type: 'content', active: 1, sort: 1,
body: `<h2>Willkommen in Brittas Nähkiste</h2>\n<p>Seit vielen Jahren liefern wir Stoffe, Garne und Kurzwaren an Hobby-Näher:innen und Profis. Unser kleines Team wählt jedes Produkt mit Sorgfalt aus — von der Meterware bis zur Stoffschere.</p>\n<p>Wir lieben es, wenn aus einer Idee ein fertiges Stück wird. Dabei begleiten wir dich mit ehrlicher Beratung, fairer Auswahl und schnellem Versand.</p>\n<p><em>Hinweis: Brittas Nähkiste ist die mitgelieferte Demo-Instanz von hd-commerce. Inhalte, Name und Farben sind im Admin frei anpassbar.</em></p>` },
{ slug: 'impressum', title: 'Impressum', type: 'legal', active: 1, sort: 2,
body: legalBody('Impressum', `<p>Umsatzsteuer-ID: DE000000000<br>Inhaltlich verantwortlich gemäß § 18 Abs. 2 MStV: Britta Mustermann, Anschrift wie oben.</p>\n<p>Dies ist ein neutraler Platzhaltertext der Demo-Instanz.</p>`) },
{ slug: 'datenschutz', title: 'Datenschutz', type: 'legal', active: 1, sort: 3,
body: legalBody('Datenschutzerklärung', `<p>Wir nehmen den Schutz deiner Daten ernst. Personenbezogene Daten werden nur im Rahmen der Bestellabwicklung verarbeitet. Die First-Party-Statistik erhebt keine personenbezogenen Rohdaten.</p>\n<p>Dies ist ein neutraler Platzhaltertext der Demo-Instanz und ersetzt keine Rechtsberatung.</p>`) },
{ slug: 'agb', title: 'AGB', type: 'legal', active: 1, sort: 4,
body: legalBody('Allgemeine Geschäftsbedingungen', `<p>Es gelten die gesetzlichen Bestimmungen. Preise verstehen sich inkl. gesetzlicher MwSt. zzgl. Versandkosten.</p>\n<p>Dies ist ein neutraler Platzhaltertext der Demo-Instanz.</p>`) },
{ slug: 'widerruf', title: 'Widerrufsrecht', type: 'legal', active: 1, sort: 5,
body: legalBody('Widerrufsbelehrung', `<p>Verbraucher:innen haben ein vierzehntägiges Widerrufsrecht. Meterware, die nach Kundenwunsch zugeschnitten wurde, kann vom Widerruf ausgeschlossen sein.</p>\n<p>Dies ist ein neutraler Platzhaltertext der Demo-Instanz.</p>`) },
];
export const SEED_POPUPS = [
{ title: 'Newsletter — 10 % Willkommensrabatt', type: 'newsletter', headline: '10 % auf deine erste Bestellung',
body: 'Trag dich in unseren Newsletter ein und erhalte 10 % Rabatt auf deine erste Bestellung in Brittas Nähkiste.',
image: '', cta_text: 'Rabatt sichern', cta_url: '', trigger: 'exit', trigger_value: '0',
target_path: '/', freq: 'days7', active: 1, sort: 1 },
];
+362
View File
@@ -0,0 +1,362 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import {
SEED_SETTINGS, SEED_PRODUCTS, SEED_CUSTOMERS, seedOrders,
SEED_SLIDES, SEED_PAGES, SEED_POPUPS,
} from './seed.js';
const DB_PATH = process.env.DB_PATH || './data/hdc.db';
try { mkdirSync(dirname(DB_PATH), { recursive: true }); } catch {}
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, value TEXT
);
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL, name TEXT NOT NULL, shortName TEXT,
priceCents INTEGER NOT NULL DEFAULT 0, category TEXT,
sizes TEXT DEFAULT '["One Size"]', images TEXT DEFAULT '[]', cardImage TEXT,
badge TEXT DEFAULT '', stock INTEGER, material TEXT DEFAULT '', features TEXT DEFAULT '[]',
featured INTEGER DEFAULT 0, sort INTEGER DEFAULT 99, desc TEXT DEFAULT '', metafields TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT, number TEXT UNIQUE, email TEXT, customer_name TEXT,
status TEXT DEFAULT 'pending', total_cents INTEGER DEFAULT 0, items TEXT DEFAULT '[]',
address TEXT DEFAULT '', created_at TEXT
);
CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT UNIQUE, city TEXT, created_at TEXT
);
CREATE TABLE IF NOT EXISTS slides (
id INTEGER PRIMARY KEY AUTOINCREMENT, image TEXT, headline TEXT, subline TEXT, link TEXT,
sort INTEGER DEFAULT 99, active INTEGER DEFAULT 1, created_at TEXT
);
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, title TEXT, body TEXT,
type TEXT DEFAULT 'content', active INTEGER DEFAULT 1, sort INTEGER DEFAULT 99
);
CREATE TABLE IF NOT EXISTS popups (
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, type TEXT DEFAULT 'newsletter',
headline TEXT, body TEXT, image TEXT, cta_text TEXT, cta_url TEXT,
trigger TEXT DEFAULT 'delay', trigger_value INTEGER DEFAULT 3, target_path TEXT DEFAULT '/',
freq TEXT DEFAULT 'session', active INTEGER DEFAULT 1, sort INTEGER DEFAULT 99, created_at TEXT
);
CREATE TABLE IF NOT EXISTS subscribers (
id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE, source TEXT, created_at TEXT
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT, path TEXT, referrer TEXT,
utm_source TEXT, utm_medium TEXT, utm_campaign TEXT, session TEXT,
value_cents INTEGER DEFAULT 0, meta TEXT DEFAULT '{}', created_at TEXT
);
CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT, url TEXT, mime TEXT, size INTEGER, created_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at);
CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
`);
// ---------- mappers ----------
const P = (r) => r && ({ ...r, sizes: JSON.parse(r.sizes || '[]'), images: JSON.parse(r.images || '[]'), features: JSON.parse(r.features || '[]'), metafields: JSON.parse(r.metafields || '{}'), featured: !!r.featured });
const O = (r) => r && ({ ...r, items: JSON.parse(r.items || '[]') });
const E = (r) => r && ({ ...r, meta: JSON.parse(r.meta || '{}') });
// ---------- seed ----------
function seedIfEmpty() {
if (db.prepare('SELECT COUNT(*) c FROM settings').get().c === 0) {
const ins = db.prepare('INSERT OR REPLACE INTO settings (key,value) VALUES (?,?)');
for (const [k, v] of Object.entries(SEED_SETTINGS)) ins.run(k, String(v));
}
if (db.prepare('SELECT COUNT(*) c FROM products').get().c === 0) {
const ins = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,sizes,images,cardImage,badge,stock,material,features,featured,sort,desc,metafields)
VALUES (@slug,@name,@shortName,@priceCents,@category,@sizes,@images,@cardImage,@badge,@stock,@material,@features,@featured,@sort,@desc,@metafields)`);
const tx = db.transaction(rows => rows.forEach(p => ins.run({ ...p, sizes: JSON.stringify(p.sizes), images: JSON.stringify(p.images), features: JSON.stringify(p.features), featured: p.featured ? 1 : 0, metafields: JSON.stringify(p.metafields || {}) })));
tx(SEED_PRODUCTS);
}
if (db.prepare('SELECT COUNT(*) c FROM customers').get().c === 0) {
const ic = db.prepare('INSERT OR IGNORE INTO customers (name,email,city,created_at) VALUES (@name,@email,@city,@created_at)');
const now = new Date().toISOString();
SEED_CUSTOMERS.forEach(c => ic.run({ ...c, created_at: now }));
}
if (db.prepare('SELECT COUNT(*) c FROM orders').get().c === 0) {
const io = db.prepare('INSERT INTO orders (number,email,customer_name,status,total_cents,items,address,created_at) VALUES (@number,@email,@customer_name,@status,@total_cents,@items,@address,@created_at)');
seedOrders().forEach(o => io.run({ ...o, items: JSON.stringify(o.items) }));
}
if (db.prepare('SELECT COUNT(*) c FROM slides').get().c === 0) {
const is = db.prepare('INSERT INTO slides (image,headline,subline,link,sort,active,created_at) VALUES (@image,@headline,@subline,@link,@sort,@active,@created_at)');
const now = new Date().toISOString();
SEED_SLIDES.forEach(s => is.run({ ...s, created_at: now }));
}
if (db.prepare('SELECT COUNT(*) c FROM pages').get().c === 0) {
const ip = db.prepare('INSERT INTO pages (slug,title,body,type,active,sort) VALUES (@slug,@title,@body,@type,@active,@sort)');
SEED_PAGES.forEach(p => ip.run(p));
}
if (db.prepare('SELECT COUNT(*) c FROM popups').get().c === 0) {
const ip = db.prepare('INSERT INTO popups (title,type,headline,body,image,cta_text,cta_url,trigger,trigger_value,target_path,freq,active,sort,created_at) VALUES (@title,@type,@headline,@body,@image,@cta_text,@cta_url,@trigger,@trigger_value,@target_path,@freq,@active,@sort,@created_at)');
const now = new Date().toISOString();
SEED_POPUPS.forEach(p => ip.run({ ...p, created_at: now }));
}
// seed some demo analytics events so the analytics dashboard is not empty
if (db.prepare('SELECT COUNT(*) c FROM events').get().c === 0) seedEvents();
}
function seedEvents() {
const sources = [
{ utm_source: 'google', utm_medium: 'organic' },
{ utm_source: 'instagram', utm_medium: 'social' },
{ utm_source: 'newsletter', utm_medium: 'email' },
{ utm_source: 'direct', utm_medium: 'none' },
{ utm_source: 'pinterest', utm_medium: 'social' },
];
const slugs = SEED_PRODUCTS.map(p => p.slug);
const ins = db.prepare(`INSERT INTO events (type,path,referrer,utm_source,utm_medium,utm_campaign,session,value_cents,meta,created_at)
VALUES (@type,@path,@referrer,@utm_source,@utm_medium,@utm_campaign,@session,@value_cents,@meta,@created_at)`);
const tx = db.transaction(() => {
for (let d = 29; d >= 0; d--) {
const base = new Date(); base.setDate(base.getDate() - d);
const visits = 30 + Math.floor(Math.random() * 50);
for (let v = 0; v < visits; v++) {
const src = sources[Math.floor(Math.random() * sources.length)];
const session = 's' + d + '_' + v;
const ts = new Date(base); ts.setHours(8 + Math.floor(Math.random() * 12), Math.floor(Math.random() * 60));
const at = ts.toISOString();
const row = { referrer: src.utm_source, utm_source: src.utm_source, utm_medium: src.utm_medium, utm_campaign: '', session, value_cents: 0, meta: '{}', created_at: at };
ins.run({ ...row, type: 'pageview', path: '/' });
if (Math.random() < 0.55) {
const slug = slugs[Math.floor(Math.random() * slugs.length)];
ins.run({ ...row, type: 'pageview', path: '/produkt/' + slug });
ins.run({ ...row, type: 'product_view', path: '/produkt/' + slug, meta: JSON.stringify({ slug }) });
if (Math.random() < 0.35) {
ins.run({ ...row, type: 'add_to_cart', path: '/produkt/' + slug, meta: JSON.stringify({ slug }) });
if (Math.random() < 0.4) {
ins.run({ ...row, type: 'checkout_start', path: '/checkout' });
if (Math.random() < 0.6) {
const val = 1500 + Math.floor(Math.random() * 4000);
ins.run({ ...row, type: 'purchase', path: '/bestellung-erfolgreich', value_cents: val, meta: JSON.stringify({ slug }) });
}
}
}
}
}
}
});
tx();
}
seedIfEmpty();
// ---------- settings ----------
export function getSettings() {
const rows = db.prepare('SELECT key,value FROM settings').all();
const o = {};
for (const r of rows) o[r.key] = r.value;
return o;
}
export const getSetting = (k, fallback = '') => { const r = db.prepare('SELECT value FROM settings WHERE key=?').get(k); return r ? r.value : fallback; };
export const setSetting = (k, v) => db.prepare('INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(k, String(v ?? ''));
export function formatPrice(cents) {
const cur = getSetting('currency', 'EUR');
const n = (Number(cents) || 0) / 100;
try { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: cur }).format(n); }
catch { return n.toFixed(2) + ' ' + cur; }
}
// ---------- products ----------
export const listProducts = () => db.prepare('SELECT * FROM products ORDER BY sort, id').all().map(P);
export const listFeatured = () => db.prepare('SELECT * FROM products WHERE featured=1 ORDER BY sort, id').all().map(P);
export const getProductBySlug = (slug) => P(db.prepare('SELECT * FROM products WHERE slug=?').get(slug));
export const getProductById = (id) => P(db.prepare('SELECT * FROM products WHERE id=?').get(Number(id)));
export const listCategories = () => [...new Set(db.prepare("SELECT category FROM products WHERE category IS NOT NULL AND category<>'' ORDER BY sort").all().map(r => r.category))];
function normProduct(d) {
const cardImage = d.cardImage || (Array.isArray(d.images) && d.images[0]) || '';
return {
slug: d.slug, name: d.name, shortName: d.shortName || d.name, priceCents: Math.round(Number(d.priceCents) || 0), category: d.category || '',
sizes: JSON.stringify(d.sizes && d.sizes.length ? d.sizes : ['One Size']), images: JSON.stringify(d.images || []), cardImage,
badge: d.badge || '', stock: (d.stock === '' || d.stock == null) ? null : Math.round(Number(d.stock)), material: d.material || '',
features: JSON.stringify(d.features || []), featured: d.featured ? 1 : 0, sort: Number(d.sort) || 99, desc: d.desc || '',
metafields: JSON.stringify(d.metafields || {}),
};
}
export function createProduct(d) {
const r = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,sizes,images,cardImage,badge,stock,material,features,featured,sort,desc,metafields)
VALUES (@slug,@name,@shortName,@priceCents,@category,@sizes,@images,@cardImage,@badge,@stock,@material,@features,@featured,@sort,@desc,@metafields)`).run(normProduct(d));
return r.lastInsertRowid;
}
export function updateProduct(id, d) {
db.prepare(`UPDATE products SET slug=@slug,name=@name,shortName=@shortName,priceCents=@priceCents,category=@category,sizes=@sizes,images=@images,cardImage=@cardImage,badge=@badge,stock=@stock,material=@material,features=@features,featured=@featured,sort=@sort,desc=@desc,metafields=@metafields WHERE id=@id`)
.run({ ...normProduct(d), id: Number(id) });
return id;
}
export const deleteProduct = (id) => db.prepare('DELETE FROM products WHERE id=?').run(Number(id));
// ---------- orders ----------
export const listOrders = () => db.prepare('SELECT * FROM orders ORDER BY datetime(created_at) DESC, id DESC').all().map(O);
export const getOrderById = (id) => O(db.prepare('SELECT * FROM orders WHERE id=?').get(Number(id)));
export const getOrderByNumber = (num) => O(db.prepare('SELECT * FROM orders WHERE number=?').get(num));
export function createOrder({ email, customer_name, items, total_cents, status = 'pending', address = '' }) {
const m = db.prepare("SELECT MAX(CAST(substr(number,5) AS INTEGER)) m FROM orders").get().m || 1000;
const number = 'BNK-' + (m + 1);
const now = new Date().toISOString();
const r = db.prepare('INSERT INTO orders (number,email,customer_name,status,total_cents,items,address,created_at) VALUES (?,?,?,?,?,?,?,?)')
.run(number, email || '', customer_name || '', status, total_cents || 0, JSON.stringify(items || []), address || '', now);
if (email) {
db.prepare('INSERT OR IGNORE INTO customers (name,email,city,created_at) VALUES (?,?,?,?)').run(customer_name || '', email, '', now);
}
recordEvent({ type: 'purchase', path: '/bestellung-erfolgreich', value_cents: total_cents || 0, meta: { number } });
return { id: r.lastInsertRowid, number };
}
export const updateOrderStatus = (id, status) => db.prepare('UPDATE orders SET status=? WHERE id=?').run(status, Number(id));
// ---------- customers ----------
export function listCustomers() {
const rows = db.prepare('SELECT * FROM customers ORDER BY id').all();
return rows.map(c => {
const agg = db.prepare("SELECT COUNT(*) cnt, COALESCE(SUM(total_cents),0) spent FROM orders WHERE email=? AND status NOT IN ('cancelled','refunded')").get(c.email);
return { ...c, orders_count: agg.cnt, total_spent_cents: agg.spent };
});
}
export const getCustomerById = (id) => db.prepare('SELECT * FROM customers WHERE id=?').get(Number(id));
// ---------- slides ----------
export const listSlides = () => db.prepare('SELECT * FROM slides ORDER BY sort, id').all();
export const listActiveSlides = () => db.prepare('SELECT * FROM slides WHERE active=1 ORDER BY sort, id').all();
export const getSlideById = (id) => db.prepare('SELECT * FROM slides WHERE id=?').get(Number(id));
export function createSlide(d) {
return db.prepare('INSERT INTO slides (image,headline,subline,link,sort,active,created_at) VALUES (?,?,?,?,?,?,?)')
.run(d.image || '', d.headline || '', d.subline || '', d.link || '', Number(d.sort) || 99, d.active ? 1 : 0, new Date().toISOString()).lastInsertRowid;
}
export function updateSlide(id, d) {
db.prepare('UPDATE slides SET image=?,headline=?,subline=?,link=?,sort=?,active=? WHERE id=?')
.run(d.image || '', d.headline || '', d.subline || '', d.link || '', Number(d.sort) || 99, d.active ? 1 : 0, Number(id));
return id;
}
export const deleteSlide = (id) => db.prepare('DELETE FROM slides WHERE id=?').run(Number(id));
// ---------- pages ----------
export const listPages = () => db.prepare('SELECT * FROM pages ORDER BY sort, id').all();
export const listActivePages = () => db.prepare('SELECT * FROM pages WHERE active=1 ORDER BY sort, id').all();
export const listLegalPages = () => db.prepare("SELECT * FROM pages WHERE active=1 AND type='legal' ORDER BY sort, id").all();
export const getPageBySlug = (slug) => db.prepare('SELECT * FROM pages WHERE slug=?').get(slug);
export const getPageById = (id) => db.prepare('SELECT * FROM pages WHERE id=?').get(Number(id));
export function createPage(d) {
return db.prepare('INSERT INTO pages (slug,title,body,type,active,sort) VALUES (?,?,?,?,?,?)')
.run(d.slug, d.title || '', d.body || '', d.type || 'content', d.active ? 1 : 0, Number(d.sort) || 99).lastInsertRowid;
}
export function updatePage(id, d) {
db.prepare('UPDATE pages SET slug=?,title=?,body=?,type=?,active=?,sort=? WHERE id=?')
.run(d.slug, d.title || '', d.body || '', d.type || 'content', d.active ? 1 : 0, Number(d.sort) || 99, Number(id));
return id;
}
export const deletePage = (id) => db.prepare('DELETE FROM pages WHERE id=?').run(Number(id));
// ---------- popups ----------
export const listPopups = () => db.prepare('SELECT * FROM popups ORDER BY sort, id').all();
export const getPopupById = (id) => db.prepare('SELECT * FROM popups WHERE id=?').get(Number(id));
export function popupsForPath(path) {
return db.prepare('SELECT * FROM popups WHERE active=1 ORDER BY sort, id').all()
.filter(p => p.target_path === '*' || p.target_path === path || (p.target_path && p.target_path.endsWith('*') && path.startsWith(p.target_path.slice(0, -1))));
}
export function createPopup(d) {
return db.prepare(`INSERT INTO popups (title,type,headline,body,image,cta_text,cta_url,trigger,trigger_value,target_path,freq,active,sort,created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
.run(d.title || '', d.type || 'newsletter', d.headline || '', d.body || '', d.image || '', d.cta_text || '', d.cta_url || '',
d.trigger || 'delay', Number(d.trigger_value) || 0, d.target_path || '/', d.freq || 'session', d.active ? 1 : 0, Number(d.sort) || 99, new Date().toISOString()).lastInsertRowid;
}
export function updatePopup(id, d) {
db.prepare(`UPDATE popups SET title=?,type=?,headline=?,body=?,image=?,cta_text=?,cta_url=?,trigger=?,trigger_value=?,target_path=?,freq=?,active=?,sort=? WHERE id=?`)
.run(d.title || '', d.type || 'newsletter', d.headline || '', d.body || '', d.image || '', d.cta_text || '', d.cta_url || '',
d.trigger || 'delay', Number(d.trigger_value) || 0, d.target_path || '/', d.freq || 'session', d.active ? 1 : 0, Number(d.sort) || 99, Number(id));
return id;
}
export const deletePopup = (id) => db.prepare('DELETE FROM popups WHERE id=?').run(Number(id));
// ---------- subscribers ----------
export function addSubscriber(email, source = 'web') {
if (!email) return { ok: false };
try { db.prepare('INSERT INTO subscribers (email,source,created_at) VALUES (?,?,?)').run(email, source, new Date().toISOString()); return { ok: true }; }
catch { return { ok: true, dup: true }; }
}
export const listSubscribers = () => db.prepare('SELECT * FROM subscribers ORDER BY id DESC').all();
// ---------- media ----------
export function addMedia(d) {
return db.prepare('INSERT INTO media (filename,url,mime,size,created_at) VALUES (?,?,?,?,?)')
.run(d.filename, d.url, d.mime || '', d.size || 0, new Date().toISOString()).lastInsertRowid;
}
export const listMedia = () => db.prepare('SELECT * FROM media ORDER BY id DESC').all();
// ---------- events / analytics ----------
export function recordEvent({ type, path = '', referrer = '', utm_source = '', utm_medium = '', utm_campaign = '', session = '', value_cents = 0, meta = {} }) {
try {
db.prepare(`INSERT INTO events (type,path,referrer,utm_source,utm_medium,utm_campaign,session,value_cents,meta,created_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`).run(type, path, referrer, utm_source, utm_medium, utm_campaign, session, Number(value_cents) || 0, JSON.stringify(meta || {}), new Date().toISOString());
} catch {}
}
export function analyticsSummary(days = 30) {
const since = new Date(); since.setDate(since.getDate() - days);
const s = since.toISOString();
const cnt = (type) => db.prepare('SELECT COUNT(*) c FROM events WHERE type=? AND created_at>=?').get(type, s).c;
const pageviews = cnt('pageview');
const productViews = cnt('product_view');
const addToCart = cnt('add_to_cart');
const checkoutStart = cnt('checkout_start');
const purchases = cnt('purchase');
const visitors = db.prepare("SELECT COUNT(DISTINCT session) c FROM events WHERE type='pageview' AND created_at>=?").get(s).c;
const revenue = db.prepare("SELECT COALESCE(SUM(value_cents),0) v FROM events WHERE type='purchase' AND created_at>=?").get(s).v;
const conversion = visitors ? (purchases / visitors) * 100 : 0;
const aov = purchases ? revenue / purchases : 0;
const bySource = db.prepare(`SELECT COALESCE(NULLIF(utm_source,''),'direct') src,
COUNT(DISTINCT session) visitors,
SUM(CASE WHEN type='purchase' THEN 1 ELSE 0 END) purchases,
COALESCE(SUM(value_cents),0) revenue
FROM events WHERE created_at>=? GROUP BY src ORDER BY revenue DESC, visitors DESC`).all(s);
const topProducts = db.prepare(`SELECT json_extract(meta,'$.slug') slug,
SUM(CASE WHEN type='product_view' THEN 1 ELSE 0 END) views,
SUM(CASE WHEN type='purchase' THEN 1 ELSE 0 END) buys
FROM events WHERE created_at>=? AND json_extract(meta,'$.slug') IS NOT NULL
GROUP BY slug ORDER BY views DESC LIMIT 8`).all(s).map(r => {
const p = getProductBySlug(r.slug);
return { slug: r.slug, name: p ? p.shortName || p.name : r.slug, views: r.views, buys: r.buys, rate: r.views ? (r.buys / r.views) * 100 : 0 };
});
const series = [];
for (let i = days - 1; i >= 0; i--) {
const d0 = new Date(); d0.setDate(d0.getDate() - i); d0.setHours(0, 0, 0, 0);
const d1 = new Date(d0); d1.setDate(d1.getDate() + 1);
const a = d0.toISOString(), b = d1.toISOString();
const views = db.prepare("SELECT COUNT(*) c FROM events WHERE type='pageview' AND created_at>=? AND created_at<?").get(a, b).c;
const rev = db.prepare("SELECT COALESCE(SUM(value_cents),0) v FROM events WHERE type='purchase' AND created_at>=? AND created_at<?").get(a, b).v;
series.push({ date: d0.toISOString().slice(0, 10), label: d0.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }), views, revenue: rev });
}
return {
days, visitors, pageviews, productViews, addToCart, checkoutStart, purchases, revenue, conversion, aov,
funnel: [
{ label: 'Aufrufe', value: pageviews },
{ label: 'Produktansichten', value: productViews },
{ label: 'In den Korb', value: addToCart },
{ label: 'Checkout', value: checkoutStart },
{ label: 'Kauf', value: purchases },
],
bySource, topProducts, series,
};
}
// ---------- dashboard ----------
export function dashboard() {
const revenue = db.prepare("SELECT COALESCE(SUM(total_cents),0) s FROM orders WHERE status NOT IN ('cancelled','refunded')").get().s;
const orderCount = db.prepare('SELECT COUNT(*) c FROM orders').get().c;
const productCount = db.prepare('SELECT COUNT(*) c FROM products').get().c;
const customerCount = db.prepare('SELECT COUNT(*) c FROM customers').get().c;
const pending = db.prepare("SELECT COUNT(*) c FROM orders WHERE status='pending'").get().c;
const recentOrders = db.prepare('SELECT * FROM orders ORDER BY datetime(created_at) DESC, id DESC LIMIT 6').all().map(O);
const lowStock = db.prepare('SELECT * FROM products WHERE stock IS NOT NULL AND stock <= 35 ORDER BY stock ASC LIMIT 6').all().map(P);
const a = analyticsSummary(30);
return { revenueCents: revenue, orderCount, productCount, customerCount, pending, recentOrders, lowStock, funnelMini: { views: a.pageviews, cart: a.addToCart, buy: a.purchases } };
}
+2
View File
@@ -0,0 +1,2 @@
// Facade — hd-commerce nutzt ausschliesslich den SQLite-Treiber.
export * from './store-sqlite.js';
+50
View File
@@ -0,0 +1,50 @@
import { recordEvent, getSetting } from './lib/store.js';
import { createHash } from 'node:crypto';
const USER = process.env.ADMIN_USER || 'admin';
const PASS = process.env.ADMIN_PASS || 'admin';
const SKIP = ['/api/', '/uploads/', '/_astro', '/favicon', '/_image', '/robots.txt'];
function sessionHash(request) {
const ua = request.headers.get('user-agent') || '';
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'local';
const day = new Date().toISOString().slice(0, 10);
return createHash('sha256').update(ip + ua + day).digest('hex').slice(0, 16);
}
export function onRequest({ request }, next) {
const url = new URL(request.url);
const path = url.pathname;
// --- Admin Basic-Auth ---
if (path.startsWith('/admin')) {
const hdr = request.headers.get('authorization') || '';
if (hdr.startsWith('Basic ')) {
let dec = ''; try { dec = atob(hdr.slice(6)); } catch {}
const i = dec.indexOf(':');
if (i > -1 && dec.slice(0, i) === USER && dec.slice(i + 1) === PASS) return next();
}
const shop = getSetting('shop_name', 'hd-commerce');
return new Response('Authentifizierung erforderlich', {
status: 401,
headers: { 'WWW-Authenticate': `Basic realm="${shop} Admin", charset="UTF-8"` },
});
}
// --- First-Party Pageview-Tracking (nur Storefront-GET-Seiten) ---
if (request.method === 'GET' && !SKIP.some(s => path.startsWith(s))) {
try {
recordEvent({
type: 'pageview', path,
referrer: request.headers.get('referer') || '',
utm_source: url.searchParams.get('utm_source') || '',
utm_medium: url.searchParams.get('utm_medium') || '',
utm_campaign: url.searchParams.get('utm_campaign') || '',
session: sessionHash(request),
});
} catch {}
}
return next();
}
+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>
+51
View File
@@ -0,0 +1,51 @@
import { createOrder, getSetting } from '../../lib/store.js';
export const prerender = false;
const keyLooksReal = (k) => typeof k === 'string' && /^sk_(test|live)_[A-Za-z0-9]{16,}/.test(k.trim());
function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); }
export async function POST({ request }) {
let body;
try { body = await request.json(); } catch { return json({ error: 'Bad request' }, 400); }
const items = Array.isArray(body.items) ? body.items : [];
const contact = body.contact || {};
if (!items.length) return json({ error: 'Warenkorb leer' }, 400);
const lineItems = items.map((i) => ({
name: i.name, size: i.size || '', qty: Math.max(1, parseInt(i.qty) || 1),
priceCents: Math.round(Number(i.priceCents) || Number(i.price) * 100 || 0), image: i.image || '',
}));
const subtotal = lineItems.reduce((s, i) => s + i.priceCents * i.qty, 0);
const freeShip = Number(getSetting('free_shipping_cents', '4900')) || 4900;
const shipping = subtotal >= freeShip ? 0 : 490;
const total = subtotal + shipping;
const customer_name = [contact.vorname, contact.nachname].filter(Boolean).join(' ').trim();
const email = contact.email || '';
const order = await createOrder({
email, customer_name, items: lineItems, total_cents: total, status: 'pending',
address: [contact.strasse, contact.plz, contact.ort, contact.land].filter(Boolean).join(', '),
});
const secret = process.env.STRIPE_SECRET_KEY || '';
const origin = new URL(request.url).origin;
if (keyLooksReal(secret)) {
try {
const Stripe = (await import('stripe')).default;
const stripe = new Stripe(secret);
const session = await stripe.checkout.sessions.create({
mode: 'payment', payment_method_types: ['card'], locale: 'de',
customer_email: email || undefined,
line_items: [
...lineItems.map((i) => ({ quantity: i.qty, price_data: { currency: 'eur', unit_amount: i.priceCents,
product_data: { name: `${i.name}${i.size ? ' · ' + i.size : ''}` } } })),
...(shipping > 0 ? [{ quantity: 1, price_data: { currency: 'eur', unit_amount: shipping, product_data: { name: 'Versand (DE)' } } }] : []),
],
success_url: `${origin}/bestellung-erfolgreich?order=${order.number}`,
cancel_url: `${origin}/warenkorb`, metadata: { order_number: order.number },
});
return json({ url: session.url });
} catch (e) { return json({ url: `/bestellung-erfolgreich?order=${order.number}&demo=1` }); }
}
return json({ url: `/bestellung-erfolgreich?order=${order.number}&demo=1` });
}
+12
View File
@@ -0,0 +1,12 @@
import { addSubscriber } from '../../lib/store.js';
export const prerender = false;
function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); }
export async function POST({ request }) {
let b;
try { b = await request.json(); } catch { return json({ ok: false }, 400); }
const email = (b.email || '').trim();
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return json({ ok: false, error: 'Ungültige E-Mail' }, 400);
const r = addSubscriber(email, b.source || 'web');
return json({ ok: true, dup: !!r.dup });
}
+15
View File
@@ -0,0 +1,15 @@
import { recordEvent } from '../../lib/store.js';
export const prerender = false;
const OK = new Set(['pageview', 'product_view', 'add_to_cart', 'checkout_start', 'purchase']);
function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); }
export async function POST({ request }) {
let b;
try { b = await request.json(); } catch { return json({ ok: false }, 400); }
if (!OK.has(b.type)) return json({ ok: false, error: 'unknown type' }, 400);
recordEvent({
type: b.type, path: b.path || '', value_cents: Number(b.value_cents) || 0,
referrer: '', session: 'client', meta: b.meta || {},
});
return json({ ok: true });
}
+25
View File
@@ -0,0 +1,25 @@
import { addMedia } from '../../lib/store.js';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
export const prerender = false;
function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); }
const UP_DIR = (process.env.DB_PATH ? dirname(process.env.DB_PATH) : './data') + '/uploads';
export async function POST({ request }) {
try {
const form = await request.formData();
const file = form.get('file');
if (!file || typeof file === 'string') return json({ ok: false, error: 'Keine Datei' }, 400);
const buf = Buffer.from(await file.arrayBuffer());
const safe = (file.name || 'datei').replace(/[^a-zA-Z0-9._-]/g, '_');
const fname = Date.now() + '-' + safe;
mkdirSync(UP_DIR, { recursive: true });
writeFileSync(`${UP_DIR}/${fname}`, buf);
const url = '/uploads/' + fname;
addMedia({ filename: fname, url, mime: file.type || '', size: buf.length });
return json({ ok: true, url });
} catch (e) {
return json({ ok: false, error: String(e && e.message || e) }, 500);
}
}
+20
View File
@@ -0,0 +1,20 @@
---
import Base from '../layouts/Base.astro';
const url = new URL(Astro.request.url);
const order = url.searchParams.get('order') || '';
const demo = url.searchParams.get('demo') === '1';
---
<Base title="Bestellung erfolgreich">
<div class="wrap">
<div class="empty-state" style="padding:90px 20px">
<div style="width:72px;height:72px;border-radius:50%;background:color-mix(in srgb,var(--accent) 16%,white);display:grid;place-items:center;margin:0 auto 20px">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</div>
<h1>Vielen Dank für deine Bestellung!</h1>
{order && <p style="font-size:18px;margin-top:10px">Deine Bestellnummer: <b style="color:var(--ink)">{order}</b></p>}
<p style="max-width:46ch;margin:10px auto 0">Wir haben deine Bestellung erhalten und melden uns per E-Mail mit den Versanddetails.</p>
{demo && <p style="font-size:13px;color:var(--faint);margin-top:14px">Demo-Hinweis: Diese Bestellung wurde ohne echte Zahlung im Demo-Modus angelegt.</p>}
<a class="btn btn-primary btn-lg" href="/shop" style="margin-top:24px">Weiter einkaufen</a>
</div>
</div>
</Base>
+60
View File
@@ -0,0 +1,60 @@
---
import Base from '../layouts/Base.astro';
import { getSetting } from '../lib/store.js';
const freeShip = Number(getSetting('free_shipping_cents', '4900')) || 4900;
const currency = getSetting('currency', 'EUR');
const hasStripe = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test((process.env.STRIPE_SECRET_KEY || '').trim());
---
<Base title="Kasse">
<div class="wrap">
<h1 style="padding:40px 0 8px">Zur Kasse</h1>
{!hasStripe && (<p style="color:var(--faint);font-size:14px">Demo-Modus: Es ist kein echter Stripe-Schlüssel hinterlegt — die Bestellung wird ohne Zahlung abgeschlossen.</p>)}
<div class="cart-wrap" style="align-items:start">
<form id="coForm">
<h3 style="margin-bottom:16px">Kontakt & Lieferadresse</h3>
<div class="form-grid">
<div class="field"><label>Vorname</label><input name="vorname" required /></div>
<div class="field"><label>Nachname</label><input name="nachname" required /></div>
<div class="field full"><label>E-Mail</label><input name="email" type="email" required /></div>
<div class="field full"><label>Straße & Hausnummer</label><input name="strasse" required /></div>
<div class="field"><label>PLZ</label><input name="plz" required /></div>
<div class="field"><label>Ort</label><input name="ort" required /></div>
<div class="field full"><label>Land</label><input name="land" value="Deutschland" /></div>
</div>
<button class="btn btn-primary btn-lg btn-block" type="submit" id="coBtn" style="margin-top:8px">Kostenpflichtig bestellen</button>
<div id="coMsg" style="margin-top:12px;color:var(--accent);font-size:14px"></div>
</form>
<div class="summary"><div id="coSummary"></div></div>
</div>
</div>
<script is:inline define:vars={{ freeShip, currency }}>
(function () {
function fmt(c) { try { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency }).format((c||0)/100); } catch(e){ return ((c||0)/100).toFixed(2)+' '+currency; } }
function summary() {
var items = window.HDC.read(), sub = window.HDC.subtotal(), ship = sub >= freeShip ? 0 : 490;
var box = document.getElementById('coSummary');
if (!items.length) { box.innerHTML = '<p>Dein Warenkorb ist leer. <a class="s-link" href="/shop">Zum Shop</a></p>'; return; }
box.innerHTML = items.map(function (i) { return '<div class="sum-row"><span>' + i.qty + '× ' + i.name + (i.size && i.size!=='One Size' ? ' ('+i.size+')':'') + '</span><span>' + fmt(i.priceCents*i.qty) + '</span></div>'; }).join('') +
'<div class="sum-row"><span>Versand</span><span>' + (ship===0?'Kostenlos':fmt(ship)) + '</span></div>' +
'<div class="sum-row total"><span>Gesamt</span><span>' + fmt(sub+ship) + '</span></div>';
}
document.addEventListener('DOMContentLoaded', function () {
summary();
var f = document.getElementById('coForm');
f.addEventListener('submit', function (e) {
e.preventDefault();
var items = window.HDC.read();
if (!items.length) { document.getElementById('coMsg').textContent = 'Dein Warenkorb ist leer.'; return; }
window.HDC.track('checkout_start', window.HDC.subtotal());
var btn = document.getElementById('coBtn'); btn.disabled = true; btn.textContent = 'Wird verarbeitet …';
var fd = new FormData(f), contact = {}; fd.forEach(function (v, k) { contact[k] = v; });
fetch('/api/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: items, contact: contact }) })
.then(function (r) { return r.json(); })
.then(function (d) { if (d.url) { window.HDC.clear(); window.location.href = d.url; } else { throw new Error(d.error || 'Fehler'); } })
.catch(function (err) { document.getElementById('coMsg').textContent = err.message; btn.disabled = false; btn.textContent = 'Kostenpflichtig bestellen'; });
});
});
})();
</script>
</Base>
+124
View File
@@ -0,0 +1,124 @@
---
import Base from '../layouts/Base.astro';
import { listActiveSlides, listFeatured, listCategories, listProducts, getSettings, formatPrice } from '../lib/store.js';
const settings = getSettings();
const slides = listActiveSlides();
const featured = listFeatured();
const categories = listCategories();
const all = listProducts();
const catCount = (c) => all.filter(p => p.category === c).length;
const shopName = settings.shop_name || 'hd-commerce';
const tagline = settings.shop_tagline || '';
---
<Base title="Start" description={tagline}>
<div class="wrap">
{slides.length > 0 && (
<div class="slider" id="hdcSlider">
<div class="slides" id="hdcSlides">
{slides.map((s) => (
<div class="slide">
{s.image && <img src={s.image} alt={s.headline} loading="eager" />}
<div class="slide-cap">
<h2>{s.headline}</h2>
{s.subline && <p>{s.subline}</p>}
{s.link && <a class="btn btn-primary btn-lg" href={s.link}>Jetzt entdecken</a>}
</div>
</div>
))}
</div>
{slides.length > 1 && (
<>
<button class="slider-arrow prev" id="hdcPrev" aria-label="Zurück"></button>
<button class="slider-arrow next" id="hdcNext" aria-label="Weiter"></button>
<div class="slider-dots" id="hdcDots">
{slides.map((_, i) => (<button class={i === 0 ? 'active' : ''} data-idx={i}></button>))}
</div>
</>
)}
</div>
)}
</div>
{categories.length > 0 && (
<section class="section section-sm">
<div class="wrap">
<div class="section-head">
<div>
<span class="eyebrow">Sortiment</span>
<h2 style="margin-top:8px">Nach Kategorie stöbern</h2>
</div>
</div>
<div class="cat-grid">
{categories.map((c) => (
<a class="cat-card" href={`/shop?cat=${encodeURIComponent(c)}`}>
<span class="ct">{c}</span>
<span class="cn">{catCount(c)} Artikel</span>
</a>
))}
</div>
</div>
</section>
)}
{featured.length > 0 && (
<section class="section">
<div class="wrap">
<div class="section-head">
<div>
<span class="eyebrow">Empfehlungen</span>
<h2 style="margin-top:8px">Beliebt bei {shopName}</h2>
<p class="lead">Handverlesene Lieblinge für dein nächstes Projekt.</p>
</div>
<a class="btn btn-ghost" href="/shop">Alle Produkte</a>
</div>
<div class="prod-grid">
{featured.map((p) => (
<a class="prod-card" href={`/produkt/${p.slug}`}>
<div class="prod-media">
{p.cardImage && <img src={p.cardImage} alt={p.name} loading="lazy" />}
{p.badge && <span class="prod-badge">{p.badge}</span>}
</div>
<div class="prod-info">
<span class="prod-cat">{p.category}</span>
<span class="prod-name">{p.shortName || p.name}</span>
<span class="prod-price">{formatPrice(p.priceCents)}</span>
</div>
</a>
))}
</div>
</div>
</section>
)}
<section class="section">
<div class="wrap">
<div class="newsletter">
<span class="eyebrow" style="color:rgba(255,255,255,.7)">Newsletter</span>
<h2 style="margin-top:10px">Bleib auf dem Laufenden</h2>
<p>Neue Stoffe, Aktionen und Näh-Inspiration — direkt in dein Postfach.</p>
<form class="nl-form" id="nlForm">
<input type="email" required placeholder="deine@email.de" />
<button class="btn btn-primary" type="submit">Anmelden</button>
</form>
<div class="nl-msg" id="nlMsg"></div>
</div>
</div>
</section>
<script src="/slider.js" is:inline></script>
<script is:inline>
(function () {
var f = document.getElementById('nlForm'); if (!f) return;
f.addEventListener('submit', function (e) {
e.preventDefault();
var email = f.querySelector('input').value;
var msg = document.getElementById('nlMsg');
fetch('/api/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, source: 'home' }) })
.then(function (r) { return r.json(); })
.then(function (d) { msg.textContent = d.ok ? 'Danke für deine Anmeldung!' : (d.error || 'Fehler'); if (d.ok) f.reset(); })
.catch(function () { msg.textContent = 'Bitte später erneut versuchen.'; });
});
})();
</script>
</Base>
+88
View File
@@ -0,0 +1,88 @@
---
import Base from '../../layouts/Base.astro';
import { getProductBySlug, listProducts, formatPrice } from '../../lib/store.js';
const { slug } = Astro.params;
const product = getProductBySlug(slug);
if (!product) return Astro.redirect('/shop');
const related = listProducts().filter(p => p.category === product.category && p.slug !== product.slug).slice(0, 4);
const gallery = product.images && product.images.length ? product.images : (product.cardImage ? [product.cardImage] : []);
const addData = { slug: product.slug, name: product.name, priceCents: product.priceCents, image: product.cardImage || gallery[0] || '', sizes: product.sizes };
---
<Base title={product.shortName || product.name} description={product.desc}>
<div class="wrap">
<div class="pdp">
<div class="pdp-gallery">
<div class="pdp-main"><img id="pdpMain" src={gallery[0]} alt={product.name} /></div>
{gallery.length > 1 && (
<div class="pdp-thumbs">
{gallery.map((g, i) => (<img src={g} alt="" class={i === 0 ? 'active' : ''} data-src={g} />))}
</div>
)}
</div>
<div class="pdp-info">
<div id="pdpData" data-slug={product.slug}></div>
<div class="pdp-cat">{product.category}</div>
<h1>{product.name}</h1>
<div class="pdp-price">{formatPrice(product.priceCents)}</div>
{product.desc && <p class="pdp-desc">{product.desc}</p>}
{product.sizes && product.sizes.length > 0 && product.sizes[0] !== 'One Size' && (
<>
<div class="s-section-title" style="margin-bottom:10px;font-weight:700;color:var(--ink)">Variante</div>
<div class="size-row">
{product.sizes.map((s, i) => (<button class={`size-chip ${i === 0 ? 'active' : ''}`} data-size={s}>{s}</button>))}
</div>
</>
)}
<button class="btn btn-primary btn-lg btn-block" data-add-to-cart data-product={JSON.stringify(addData)}>In den Warenkorb</button>
{product.features && product.features.length > 0 && (
<ul class="feat-list">{product.features.map((f) => (<li>{f}</li>))}</ul>
)}
<div class="pdp-meta">
{product.material && <div><b>Material:</b> {product.material}</div>}
{product.stock != null && <div><b>Verfügbarkeit:</b> {product.stock > 0 ? `${product.stock} auf Lager` : 'Ausverkauft'}</div>}
</div>
</div>
</div>
{related.length > 0 && (
<section class="section">
<div class="section-head"><div><span class="eyebrow">Passt dazu</span><h2 style="margin-top:8px">Ähnliche Produkte</h2></div></div>
<div class="prod-grid">
{related.map((p) => (
<a class="prod-card" href={`/produkt/${p.slug}`}>
<div class="prod-media">
{p.cardImage && <img src={p.cardImage} alt={p.name} loading="lazy" />}
{p.badge && <span class="prod-badge">{p.badge}</span>}
</div>
<div class="prod-info">
<span class="prod-cat">{p.category}</span>
<span class="prod-name">{p.shortName || p.name}</span>
<span class="prod-price">{formatPrice(p.priceCents)}</span>
</div>
</a>
))}
</div>
</section>
)}
</div>
<script is:inline>
(function () {
var main = document.getElementById('pdpMain');
document.querySelectorAll('.pdp-thumbs img').forEach(function (t) {
t.addEventListener('click', function () {
main.src = t.getAttribute('data-src');
document.querySelectorAll('.pdp-thumbs img').forEach(function (x) { x.classList.remove('active'); });
t.classList.add('active');
});
});
})();
</script>
</Base>
+16
View File
@@ -0,0 +1,16 @@
---
import Base from '../../layouts/Base.astro';
import { getPageBySlug } from '../../lib/store.js';
const { slug } = Astro.params;
const page = getPageBySlug(slug);
if (!page || !page.active) return Astro.redirect('/');
---
<Base title={page.title}>
<div class="wrap">
<article class="prose">
<h1>{page.title}</h1>
<div set:html={page.body}></div>
</article>
</div>
</Base>
+49
View File
@@ -0,0 +1,49 @@
---
import Base from '../layouts/Base.astro';
import { listProducts, listCategories, formatPrice } from '../lib/store.js';
const products = listProducts();
const categories = listCategories();
const url = new URL(Astro.request.url);
const activeCat = url.searchParams.get('cat') || '';
const filtered = activeCat ? products.filter(p => p.category === activeCat) : products;
---
<Base title="Shop">
<section class="shop-head">
<div class="wrap">
<span class="eyebrow">Sortiment</span>
<h1 style="margin-top:10px">{activeCat || 'Alle Produkte'}</h1>
<div class="filter-row">
<a class={`filter-chip ${!activeCat ? 'active' : ''}`} href="/shop">Alle</a>
{categories.map((c) => (
<a class={`filter-chip ${activeCat === c ? 'active' : ''}`} href={`/shop?cat=${encodeURIComponent(c)}`}>{c}</a>
))}
</div>
</div>
</section>
<section style="padding-bottom:64px">
<div class="wrap">
{filtered.length === 0 ? (
<div class="empty-state"><h2>Keine Produkte gefunden</h2><p>In dieser Kategorie ist aktuell nichts verfügbar.</p></div>
) : (
<div class="prod-grid">
{filtered.map((p) => (
<a class="prod-card" href={`/produkt/${p.slug}`}>
<div class="prod-media">
{p.cardImage && <img src={p.cardImage} alt={p.name} loading="lazy" />}
{p.badge && <span class="prod-badge">{p.badge}</span>}
</div>
<div class="prod-info">
<span class="prod-cat">{p.category}</span>
<span class="prod-name">{p.shortName || p.name}</span>
{p.stock === 0 && <span class="soldout">Ausverkauft</span>}
<span class="prod-price">{formatPrice(p.priceCents)}</span>
</div>
</a>
))}
</div>
)}
</div>
</section>
</Base>
+15
View File
@@ -0,0 +1,15 @@
import { readFileSync, existsSync, statSync } from 'node:fs';
import { dirname, extname } from 'node:path';
export const prerender = false;
const UP_DIR = (process.env.DB_PATH ? dirname(process.env.DB_PATH) : './data') + '/uploads';
const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.avif': 'image/avif' };
export async function GET({ params }) {
const file = (params.file || '').replace(/\.\./g, '').replace(/^\/+/, '');
const full = `${UP_DIR}/${file}`;
if (!file || !existsSync(full) || !statSync(full).isFile()) return new Response('Not found', { status: 404 });
const data = readFileSync(full);
const mime = MIME[extname(full).toLowerCase()] || 'application/octet-stream';
return new Response(data, { headers: { 'Content-Type': mime, 'Cache-Control': 'public, max-age=86400' } });
}
+48
View File
@@ -0,0 +1,48 @@
---
import Base from '../layouts/Base.astro';
import { getSetting } from '../lib/store.js';
const freeShip = Number(getSetting('free_shipping_cents', '4900')) || 4900;
const currency = getSetting('currency', 'EUR');
---
<Base title="Warenkorb">
<div class="wrap">
<h1 style="padding:40px 0 8px">Warenkorb</h1>
<div id="cartRoot" data-freeship={freeShip} data-currency={currency}></div>
</div>
<script is:inline define:vars={{ freeShip, currency }}>
(function () {
var root = document.getElementById('cartRoot');
function fmt(c) { try { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency }).format((c||0)/100); } catch(e){ return ((c||0)/100).toFixed(2)+' '+currency; } }
function render() {
var items = window.HDC.read();
if (!items.length) {
root.innerHTML = '<div class="empty-state"><h2>Dein Warenkorb ist leer</h2><p>Stöbere im Shop und füge deine Lieblingsprodukte hinzu.</p><a class="btn btn-primary btn-lg" href="/shop" style="margin-top:16px">Zum Shop</a></div>';
return;
}
var sub = window.HDC.subtotal();
var ship = sub >= freeShip ? 0 : 490;
var rows = items.map(function (i, idx) {
return '<div class="cart-line">' +
(i.image ? '<img src="' + i.image + '" alt="">' : '<div style="width:84px;height:104px;background:var(--sunken);border-radius:10px"></div>') +
'<div class="cl-body"><div class="cl-name">' + i.name + '</div>' +
(i.size && i.size !== 'One Size' ? '<div class="cl-size">' + i.size + '</div>' : '') +
'<div class="qty-ctl"><button data-dec="' + idx + '"></button><span>' + i.qty + '</span><button data-inc="' + idx + '">+</button></div>' +
'<button class="cl-remove" data-rm="' + idx + '">Entfernen</button></div>' +
'<div class="cl-price">' + fmt(i.priceCents * i.qty) + '</div></div>';
}).join('');
root.innerHTML = '<div class="cart-wrap"><div>' + rows + '</div>' +
'<div class="summary"><div class="sum-row"><span>Zwischensumme</span><span>' + fmt(sub) + '</span></div>' +
'<div class="sum-row"><span>Versand</span><span>' + (ship === 0 ? 'Kostenlos' : fmt(ship)) + '</span></div>' +
(ship > 0 ? '<div class="sum-row" style="font-size:13px;color:var(--accent)"><span>Noch ' + fmt(freeShip - sub) + ' bis Gratis-Versand</span><span></span></div>' : '') +
'<div class="sum-row total"><span>Gesamt</span><span>' + fmt(sub + ship) + '</span></div>' +
'<a class="btn btn-primary btn-lg btn-block" href="/checkout" style="margin-top:16px">Zur Kasse</a>' +
'<a class="btn btn-ghost btn-block" href="/shop" style="margin-top:10px">Weiter einkaufen</a></div></div>';
root.querySelectorAll('[data-inc]').forEach(function (b) { b.onclick = function () { var i = +b.getAttribute('data-inc'); window.HDC.setQty(i, window.HDC.read()[i].qty + 1); render(); }; });
root.querySelectorAll('[data-dec]').forEach(function (b) { b.onclick = function () { var i = +b.getAttribute('data-dec'); window.HDC.setQty(i, window.HDC.read()[i].qty - 1); render(); }; });
root.querySelectorAll('[data-rm]').forEach(function (b) { b.onclick = function () { window.HDC.remove(+b.getAttribute('data-rm')); render(); }; });
}
document.addEventListener('DOMContentLoaded', render);
})();
</script>
</Base>
+111
View File
@@ -0,0 +1,111 @@
/* hd-commerce Admin — Shopify-inspiriert, hell/grau. Akzentfarbe aus settings (CSS-Var --accent). */
:root{
--s-bg:#f1f1f1; --s-surface:#ffffff; --s-sunken:#f6f6f7;
--s-border:#e1e3e5; --s-border-2:#d2d5d8;
--s-ink:#1a1a1a; --s-text:#303030; --s-subtle:#616161; --s-faint:#8a8a8a;
--accent:#b8566a; --accent-dark:#8d3f50;
--s-acc-l:color-mix(in srgb,var(--accent) 14%, white);
--s-amber:#ffd79d; --s-amber-t:#5e4200; --s-amber-bg:#fff5ea;
--s-red:#fee9e8; --s-red-t:#8e1f0b;
--s-blue:#ebf0ff; --s-blue-t:#1f3d7a;
--s-gray:#e3e3e3; --s-gray-t:#4a4a4a;
--s-radius:10px; --s-shadow:0 1px 0 rgba(0,0,0,.04),0 1px 3px rgba(0,0,0,.06);
--s-font:'Public Sans Variable',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
}
*{box-sizing:border-box}
.admin-body{margin:0;background:var(--s-bg);color:var(--s-text);font-family:var(--s-font);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased}
.admin-body a{color:inherit;text-decoration:none}
.admin-shell{display:grid;grid-template-columns:236px 1fr;min-height:100vh}
.s-side{background:var(--s-surface);border-right:1px solid var(--s-border);display:flex;flex-direction:column;position:sticky;top:0;height:100vh}
.s-brand{display:flex;align-items:center;gap:10px;padding:16px 16px 14px;border-bottom:1px solid var(--s-border)}
.s-brand-logo{width:30px;height:30px;border-radius:7px;background:var(--accent);color:#fff;display:grid;place-items:center;font-weight:800;font-size:13px}
.s-brand-name{font-weight:700;font-size:14px;color:var(--s-ink);line-height:1.1}
.s-brand-sub{font-size:11px;color:var(--s-faint)}
.s-nav{padding:10px 8px;display:flex;flex-direction:column;gap:2px;flex:1;overflow:auto}
.s-nav a{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:8px;font-weight:500;font-size:13.5px;color:var(--s-text)}
.s-nav a svg{width:18px;height:18px;flex:none;color:var(--s-subtle)}
.s-nav a:hover{background:var(--s-sunken)}
.s-nav a.active{background:var(--s-acc-l);color:var(--accent-dark);font-weight:600}
.s-nav a.active svg{color:var(--accent-dark)}
.s-nav-sec{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--s-faint);padding:14px 12px 6px;font-weight:700}
.s-side-foot{padding:12px 14px;border-top:1px solid var(--s-border);font-size:12px;color:var(--s-faint)}
.s-side-foot a{color:var(--accent);font-weight:600}
.s-main{display:flex;flex-direction:column;min-width:0}
.s-topbar{position:sticky;top:0;z-index:10;background:var(--s-surface);border-bottom:1px solid var(--s-border);padding:14px 28px;display:flex;align-items:center;justify-content:space-between;gap:16px}
.s-crumbs{font-size:12px;color:var(--s-faint);margin-bottom:3px;display:flex;align-items:center;gap:6px}
.s-crumbs a{color:var(--s-subtle)}.s-crumbs a:hover{color:var(--accent)}
.s-title{font-size:20px;font-weight:700;color:var(--s-ink);letter-spacing:-.01em}
.s-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.s-content{padding:24px 28px 60px;max-width:1120px;width:100%}
.s-btn{display:inline-flex;align-items:center;gap:7px;border-radius:8px;font-size:13px;font-weight:600;padding:8px 14px;cursor:pointer;border:1px solid var(--s-border-2);background:var(--s-surface);color:var(--s-text);transition:.12s;font-family:inherit}
.s-btn:hover{background:var(--s-sunken)}
.s-btn-primary{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:0 1px 0 rgba(0,0,0,.08)}
.s-btn-primary:hover{background:var(--accent-dark);border-color:var(--accent-dark)}
.s-btn-danger{color:var(--s-red-t);border-color:#f0c6c0}.s-btn-danger:hover{background:var(--s-red)}
.s-btn-sm{padding:5px 10px;font-size:12px}
.s-card{background:var(--s-surface);border:1px solid var(--s-border);border-radius:var(--s-radius);box-shadow:var(--s-shadow)}
.s-card-pad{padding:18px 20px}
.s-card-head{padding:14px 20px;border-bottom:1px solid var(--s-border);font-weight:700;color:var(--s-ink);font-size:14px;display:flex;justify-content:space-between;align-items:center}
.s-grid{display:grid;gap:16px}
.s-stack{display:flex;flex-direction:column;gap:16px}
.s-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
.s-kpi{background:var(--s-surface);border:1px solid var(--s-border);border-radius:var(--s-radius);padding:16px 18px;box-shadow:var(--s-shadow)}
.s-kpi-label{font-size:12px;color:var(--s-subtle);font-weight:600;display:flex;align-items:center;gap:7px}
.s-kpi-val{font-size:26px;font-weight:700;color:var(--s-ink);margin-top:8px;letter-spacing:-.02em}
.s-kpi-sub{font-size:12px;color:var(--s-faint);margin-top:2px}
.s-table-wrap{overflow:auto}
.s-table{width:100%;border-collapse:collapse;font-size:13.5px}
.s-table th{text-align:left;padding:11px 16px;font-size:12px;color:var(--s-subtle);font-weight:600;background:var(--s-sunken);border-bottom:1px solid var(--s-border);white-space:nowrap}
.s-table td{padding:12px 16px;border-bottom:1px solid var(--s-border);vertical-align:middle}
.s-table tr:last-child td{border-bottom:none}
.s-table tbody tr.clk{cursor:pointer}
.s-table tbody tr.clk:hover{background:var(--s-sunken)}
.s-table .num{text-align:right;font-variant-numeric:tabular-nums}
.s-prodcell{display:flex;align-items:center;gap:12px}
.s-prodcell img{width:38px;height:46px;object-fit:cover;border-radius:6px;border:1px solid var(--s-border);background:#f0ede8}
.s-prodcell .nm{font-weight:600;color:var(--s-ink)}
.s-muted{color:var(--s-faint)}
.s-link{color:var(--accent);font-weight:600}
.s-badge{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600;line-height:1.4}
.s-badge::before{content:'';width:7px;height:7px;border-radius:50%;background:currentColor;opacity:.85}
.s-badge.green{background:#e3f1ed;color:#004c3f}
.s-badge.amber{background:var(--s-amber-bg);color:var(--s-amber-t)}
.s-badge.gray{background:var(--s-gray);color:var(--s-gray-t)}
.s-badge.red{background:var(--s-red);color:var(--s-red-t)}
.s-badge.blue{background:var(--s-blue);color:var(--s-blue-t)}
.s-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.s-field{display:flex;flex-direction:column;gap:6px;margin-bottom:16px}
.s-field.full{grid-column:1/-1}
.s-label{font-size:13px;font-weight:600;color:var(--s-text)}
.s-input,.s-textarea,.s-select{border:1px solid var(--s-border-2);border-radius:8px;padding:9px 12px;font:inherit;font-size:14px;background:var(--s-surface);color:var(--s-ink);width:100%;font-family:inherit}
.s-textarea{min-height:120px;resize:vertical;line-height:1.5}
.s-input:focus,.s-textarea:focus,.s-select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--s-acc-l)}
.s-input[type=color]{padding:4px;height:40px;cursor:pointer}
.s-help{font-size:12px;color:var(--s-faint)}
.s-check{display:flex;align-items:center;gap:8px;font-size:14px}
.s-check input{width:16px;height:16px;accent-color:var(--accent)}
.s-two-col{display:grid;grid-template-columns:1fr 320px;gap:16px;align-items:start}
.s-flash{background:var(--s-acc-l);color:var(--accent-dark);border:1px solid color-mix(in srgb,var(--accent) 30%, white);padding:10px 16px;border-radius:8px;font-weight:600;margin-bottom:16px;display:flex;align-items:center;gap:8px}
.s-empty{padding:40px;text-align:center;color:var(--s-faint)}
.s-section-title{font-size:13px;font-weight:700;color:var(--s-ink);margin:0 0 4px}
/* funnel */
.s-funnel{display:flex;flex-direction:column;gap:10px}
.s-funnel-row{display:flex;align-items:center;gap:14px}
.s-funnel-row .fl{width:140px;font-size:13px;color:var(--s-subtle);font-weight:600;flex:none}
.s-funnel-bar{flex:1;height:30px;background:var(--s-sunken);border-radius:6px;overflow:hidden;position:relative}
.s-funnel-fill{height:100%;background:var(--accent);border-radius:6px;display:flex;align-items:center;padding:0 10px;color:#fff;font-size:12px;font-weight:700;min-width:38px}
.s-funnel-mini{display:flex;align-items:center;gap:10px;margin-top:6px}
.s-fm-step{flex:1;text-align:center;background:var(--s-sunken);border-radius:8px;padding:12px 8px}
.s-fm-step .v{font-size:20px;font-weight:700;color:var(--s-ink)}
.s-fm-step .l{font-size:11px;color:var(--s-subtle);font-weight:600}
.s-fm-arrow{color:var(--s-faint);font-size:18px}
.s-bar-track{height:8px;background:var(--s-sunken);border-radius:999px;overflow:hidden}
.s-bar-track i{display:block;height:100%;background:var(--accent);border-radius:999px}
.s-media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px}
.s-media-item{border:1px solid var(--s-border);border-radius:8px;overflow:hidden;background:var(--s-surface)}
.s-media-item img{width:100%;height:110px;object-fit:cover;background:var(--s-sunken)}
.s-media-item .mi{padding:8px 10px;font-size:11px;color:var(--s-subtle);display:flex;flex-direction:column;gap:6px}
.s-tabs{display:flex;gap:6px;border-bottom:1px solid var(--s-border);margin-bottom:20px;flex-wrap:wrap}
.s-tab{padding:9px 14px;font-size:13.5px;font-weight:600;color:var(--s-subtle);border-bottom:2px solid transparent;cursor:pointer}
.s-tab.active{color:var(--accent-dark);border-color:var(--accent)}
@media(max-width:860px){.admin-shell{grid-template-columns:1fr}.s-side{position:static;height:auto}.s-nav{flex-direction:row;flex-wrap:wrap}.s-kpis{grid-template-columns:1fr 1fr}.s-form-grid{grid-template-columns:1fr}.s-two-col{grid-template-columns:1fr}}
+199
View File
@@ -0,0 +1,199 @@
/* hd-commerce Storefront — hell, editorial, neutral. Akzent kommt aus settings (CSS-Var). */
:root{
--accent:#b8566a; --accent-dark:#8d3f50;
--bg:#fbf9f7; --surface:#ffffff; --sunken:#f4f1ee;
--ink:#1d1a18; --text:#3a3531; --subtle:#6b635c; --faint:#9a918a;
--border:#e7e1da; --border-2:#d8d0c7;
--radius:14px; --radius-sm:10px;
--shadow:0 1px 2px rgba(20,15,10,.04), 0 8px 24px rgba(20,15,10,.05);
--shadow-lg:0 12px 40px rgba(20,15,10,.12);
--serif:'Fraunces Variable', Georgia, 'Times New Roman', serif;
--sans:'Public Sans Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--maxw:1180px;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{margin:0;background:var(--bg);color:var(--text);font-family:var(--sans);font-size:16px;line-height:1.6;-webkit-font-smoothing:antialiased}
a{color:inherit;text-decoration:none}
img{max-width:100%;display:block}
h1,h2,h3,h4{font-family:var(--serif);color:var(--ink);font-weight:560;line-height:1.12;letter-spacing:-.01em;margin:0}
h1{font-size:clamp(2.1rem,5vw,3.4rem)}
h2{font-size:clamp(1.6rem,3.5vw,2.4rem)}
h3{font-size:1.3rem}
p{margin:0 0 1rem}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;font-family:var(--sans);font-weight:650;font-size:15px;padding:13px 24px;border-radius:999px;cursor:pointer;border:1.5px solid transparent;transition:.18s;line-height:1}
.btn-primary{background:var(--accent);color:#fff}
.btn-primary:hover{background:var(--accent-dark)}
.btn-ghost{background:transparent;border-color:var(--border-2);color:var(--ink)}
.btn-ghost:hover{border-color:var(--accent);color:var(--accent)}
.btn-block{width:100%}
.btn-lg{padding:15px 30px;font-size:16px}
.pill{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--accent);background:color-mix(in srgb,var(--accent) 12%, white);padding:5px 12px;border-radius:999px}
.eyebrow{font-size:13px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--accent)}
/* announcement */
.announce{background:var(--accent);color:#fff;text-align:center;font-size:13.5px;font-weight:600;padding:9px 16px}
.announce a{text-decoration:underline;text-underline-offset:2px}
/* header */
.site-head{position:sticky;top:0;z-index:40;background:color-mix(in srgb,var(--surface) 86%, transparent);backdrop-filter:blur(12px);border-bottom:1px solid var(--border)}
.site-head-row{display:flex;align-items:center;justify-content:space-between;gap:24px;height:70px}
.brand-mark{font-family:var(--serif);font-size:23px;font-weight:600;color:var(--ink);letter-spacing:-.01em;display:flex;align-items:center;gap:9px}
.brand-mark .uscore{width:26px;height:4px;border-radius:2px;background:var(--accent);display:inline-block}
.main-nav{display:flex;gap:30px;font-size:15px;font-weight:550}
.main-nav a{color:var(--text);padding:6px 0;border-bottom:2px solid transparent;transition:.15s}
.main-nav a:hover{color:var(--accent);border-color:var(--accent)}
.head-actions{display:flex;align-items:center;gap:14px}
.icon-btn{position:relative;display:grid;place-items:center;width:42px;height:42px;border-radius:999px;color:var(--ink);transition:.15s}
.icon-btn:hover{background:var(--sunken)}
.icon-btn svg{width:22px;height:22px}
.cart-badge{position:absolute;top:4px;right:4px;min-width:18px;height:18px;padding:0 4px;border-radius:999px;background:var(--accent);color:#fff;font-size:11px;font-weight:700;display:none;align-items:center;justify-content:center;line-height:1}
.cart-badge.show{display:flex}
.menu-toggle{display:none}
/* hero / slider */
.slider{position:relative;overflow:hidden;border-radius:var(--radius);box-shadow:var(--shadow);margin-top:28px}
.slides{display:flex;transition:transform .6s cubic-bezier(.4,0,.2,1)}
.slide{min-width:100%;position:relative;aspect-ratio:21/8;background:var(--sunken)}
.slide img{width:100%;height:100%;object-fit:cover}
.slide-cap{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;gap:14px;padding:0 clamp(28px,6vw,80px);background:linear-gradient(90deg,rgba(20,15,12,.62) 0%,rgba(20,15,12,.25) 55%,transparent 100%);color:#fff}
.slide-cap h2{color:#fff;max-width:18ch}
.slide-cap p{color:rgba(255,255,255,.92);max-width:42ch;font-size:17px;margin:0}
.slide-cap .btn{align-self:flex-start;margin-top:6px}
.slider-dots{position:absolute;bottom:18px;left:50%;transform:translateX(-50%);display:flex;gap:9px;z-index:3}
.slider-dots button{width:9px;height:9px;border-radius:999px;border:none;background:rgba(255,255,255,.5);cursor:pointer;padding:0;transition:.2s}
.slider-dots button.active{background:#fff;width:26px}
.slider-arrow{position:absolute;top:50%;transform:translateY(-50%);z-index:3;width:44px;height:44px;border-radius:999px;border:none;background:rgba(255,255,255,.85);color:var(--ink);cursor:pointer;display:grid;place-items:center}
.slider-arrow:hover{background:#fff}
.slider-arrow.prev{left:16px}.slider-arrow.next{right:16px}
/* sections */
.section{padding:64px 0}
.section-sm{padding:44px 0}
.section-head{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:32px;flex-wrap:wrap}
.section-head .lead{max-width:48ch;color:var(--subtle);margin:8px 0 0}
/* categories */
.cat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:16px}
.cat-card{display:flex;flex-direction:column;gap:4px;padding:22px 20px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);transition:.18s}
.cat-card:hover{border-color:var(--accent);transform:translateY(-3px);box-shadow:var(--shadow)}
.cat-card .ct{font-family:var(--serif);font-size:18px;color:var(--ink)}
.cat-card .cn{font-size:13px;color:var(--faint)}
/* product grid */
.prod-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:26px}
.prod-card{display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;transition:.2s}
.prod-card:hover{transform:translateY(-4px);box-shadow:var(--shadow-lg);border-color:var(--border-2)}
.prod-media{position:relative;aspect-ratio:9/11;background:var(--sunken);overflow:hidden}
.prod-media img{width:100%;height:100%;object-fit:cover;transition:transform .5s}
.prod-card:hover .prod-media img{transform:scale(1.04)}
.prod-badge{position:absolute;top:12px;left:12px;background:var(--surface);color:var(--accent);font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;padding:5px 11px;border-radius:999px;box-shadow:var(--shadow)}
.prod-info{padding:16px 18px 20px;display:flex;flex-direction:column;gap:6px;flex:1}
.prod-cat{font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--faint)}
.prod-name{font-family:var(--serif);font-size:18px;color:var(--ink);line-height:1.2}
.prod-price{margin-top:auto;font-size:17px;font-weight:700;color:var(--ink);padding-top:8px}
.soldout{font-size:12px;color:var(--accent);font-weight:600}
/* product detail */
.pdp{display:grid;grid-template-columns:1.1fr 1fr;gap:48px;padding:40px 0 60px}
.pdp-gallery{display:flex;flex-direction:column;gap:14px}
.pdp-main{aspect-ratio:9/11;border-radius:var(--radius);overflow:hidden;background:var(--sunken);box-shadow:var(--shadow)}
.pdp-main img{width:100%;height:100%;object-fit:cover}
.pdp-thumbs{display:flex;gap:10px}
.pdp-thumbs img{width:72px;height:88px;object-fit:cover;border-radius:10px;border:1.5px solid var(--border);cursor:pointer}
.pdp-thumbs img.active{border-color:var(--accent)}
.pdp-info .pdp-cat{font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);font-weight:700}
.pdp-info h1{margin:8px 0 14px}
.pdp-price{font-size:28px;font-weight:700;color:var(--ink);margin-bottom:20px}
.pdp-desc{color:var(--subtle);font-size:16.5px;margin-bottom:24px}
.size-row{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:24px}
.size-chip{padding:10px 18px;border:1.5px solid var(--border-2);border-radius:999px;cursor:pointer;font-weight:600;font-size:14px;background:var(--surface);transition:.15s}
.size-chip:hover{border-color:var(--accent)}
.size-chip.active{border-color:var(--accent);background:var(--accent);color:#fff}
.feat-list{list-style:none;padding:0;margin:24px 0;display:flex;flex-direction:column;gap:10px}
.feat-list li{display:flex;gap:10px;align-items:flex-start;color:var(--text);font-size:15px}
.feat-list li::before{content:'';flex:none;width:18px;height:18px;margin-top:2px;border-radius:50%;background:color-mix(in srgb,var(--accent) 18%, white);background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23b8566a' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:center}
.pdp-meta{border-top:1px solid var(--border);padding-top:18px;margin-top:8px;font-size:14px;color:var(--subtle)}
.pdp-meta b{color:var(--ink)}
/* shop layout */
.shop-head{padding:44px 0 8px}
.filter-row{display:flex;gap:10px;flex-wrap:wrap;margin:24px 0 32px}
.filter-chip{padding:9px 18px;border:1.5px solid var(--border-2);border-radius:999px;background:var(--surface);cursor:pointer;font-weight:600;font-size:14px;color:var(--text);transition:.15s}
.filter-chip:hover{border-color:var(--accent)}
.filter-chip.active{background:var(--ink);border-color:var(--ink);color:#fff}
/* cart */
.cart-wrap{display:grid;grid-template-columns:1fr 360px;gap:40px;padding:40px 0 60px;align-items:start}
.cart-line{display:flex;gap:16px;padding:18px 0;border-bottom:1px solid var(--border)}
.cart-line img{width:84px;height:104px;object-fit:cover;border-radius:10px;background:var(--sunken)}
.cart-line .cl-body{flex:1;display:flex;flex-direction:column;gap:4px}
.cart-line .cl-name{font-family:var(--serif);font-size:17px;color:var(--ink)}
.cart-line .cl-size{font-size:13px;color:var(--faint)}
.qty-ctl{display:inline-flex;align-items:center;border:1.5px solid var(--border-2);border-radius:999px;overflow:hidden;margin-top:6px}
.qty-ctl button{width:32px;height:32px;border:none;background:var(--surface);cursor:pointer;font-size:16px;color:var(--ink)}
.qty-ctl button:hover{background:var(--sunken)}
.qty-ctl span{min-width:34px;text-align:center;font-weight:600}
.cl-remove{font-size:13px;color:var(--faint);cursor:pointer;background:none;border:none;text-decoration:underline;align-self:flex-start;margin-top:6px}
.cl-price{font-weight:700;color:var(--ink)}
.summary{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:24px;box-shadow:var(--shadow);position:sticky;top:90px}
.sum-row{display:flex;justify-content:space-between;padding:8px 0;font-size:15px;color:var(--subtle)}
.sum-row.total{border-top:1px solid var(--border);margin-top:8px;padding-top:16px;font-size:19px;font-weight:700;color:var(--ink)}
.empty-state{text-align:center;padding:80px 20px;color:var(--faint)}
.empty-state h2{margin-bottom:10px}
/* forms (storefront) */
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.field{display:flex;flex-direction:column;gap:6px;margin-bottom:4px}
.field.full{grid-column:1/-1}
.field label{font-size:14px;font-weight:600;color:var(--text)}
.field input,.field select,.field textarea{border:1.5px solid var(--border-2);border-radius:10px;padding:12px 14px;font:inherit;font-size:15px;background:var(--surface);color:var(--ink)}
.field input:focus,.field select:focus,.field textarea:focus{outline:none;border-color:var(--accent)}
/* newsletter */
.newsletter{background:var(--ink);color:#fff;border-radius:var(--radius);padding:clamp(36px,6vw,64px);text-align:center}
.newsletter h2{color:#fff}
.newsletter p{color:rgba(255,255,255,.8);max-width:46ch;margin:10px auto 24px}
.nl-form{display:flex;gap:10px;max-width:460px;margin:0 auto;flex-wrap:wrap}
.nl-form input{flex:1;min-width:200px;border:none;border-radius:999px;padding:14px 20px;font:inherit;font-size:15px}
.nl-msg{margin-top:14px;font-size:14px;color:#fff;min-height:18px}
/* content page */
.prose{max-width:760px;margin:0 auto;padding:48px 0 64px}
.prose h1{margin-bottom:24px}
.prose h2{margin:32px 0 12px}
.prose p,.prose li{color:var(--text);font-size:16.5px}
.prose a{color:var(--accent);text-decoration:underline}
/* footer */
.site-foot{background:var(--surface);border-top:1px solid var(--border);margin-top:40px;padding:56px 0 32px}
.foot-grid{display:grid;grid-template-columns:1.6fr 1fr 1fr 1fr;gap:32px}
.foot-brand .brand-mark{margin-bottom:12px}
.foot-brand p{color:var(--subtle);font-size:14.5px;max-width:34ch}
.foot-col h4{font-family:var(--sans);font-size:13px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--ink);margin-bottom:14px}
.foot-col a{display:block;color:var(--subtle);font-size:14.5px;padding:5px 0;transition:.15s}
.foot-col a:hover{color:var(--accent)}
.foot-bottom{border-top:1px solid var(--border);margin-top:40px;padding-top:24px;display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;font-size:13.5px;color:var(--faint)}
/* popup */
.hdc-popup-overlay{position:fixed;inset:0;background:rgba(20,15,12,.55);backdrop-filter:blur(3px);z-index:120;display:flex;align-items:center;justify-content:center;padding:20px;opacity:0;pointer-events:none;transition:.3s}
.hdc-popup-overlay.show{opacity:1;pointer-events:auto}
.hdc-popup{background:var(--surface);border-radius:var(--radius);max-width:440px;width:100%;padding:36px 32px;text-align:center;box-shadow:var(--shadow-lg);position:relative;transform:translateY(12px);transition:.3s}
.hdc-popup-overlay.show .hdc-popup{transform:none}
.hdc-popup .px{position:absolute;top:14px;right:16px;background:none;border:none;font-size:24px;color:var(--faint);cursor:pointer;line-height:1}
.hdc-popup h3{font-size:24px;margin-bottom:10px}
.hdc-popup p{color:var(--subtle);margin-bottom:20px}
.hdc-popup .nl-form{flex-direction:column}
.hdc-popup .nl-form input{border:1.5px solid var(--border-2);width:100%}
@media(max-width:880px){
.pdp{grid-template-columns:1fr;gap:28px}
.cart-wrap{grid-template-columns:1fr}
.foot-grid{grid-template-columns:1fr 1fr}
.main-nav{display:none}
.menu-toggle{display:grid}
.slide{aspect-ratio:4/3}
.form-grid{grid-template-columns:1fr}
}
@media(max-width:560px){.foot-grid{grid-template-columns:1fr}}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": { "strictNullChecks": true }
}