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