commit 4e8a3ab1053fd75e69ffde50975da63552192fac Author: Heidrich Digital Date: Wed Jun 17 12:05:29 2026 +0000 hd-commerce: neutrales SQLite-Commerce-Backend (Admin + API + Demo-Storefront) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e3f1bce --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.astro +.git +data +*.log +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..056a5e8 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# hd-commerce — Umgebungsvariablen +# Pfad zur SQLite-Datenbank (wird automatisch angelegt) +DB_PATH=./data/hdc.db + +# Admin-Zugang (Basic-Auth fuer /admin) +ADMIN_USER=admin +ADMIN_PASS=admin + +# Stripe (optional). Ohne echte Keys laeuft der Demo-Checkout-Fallback. +STRIPE_PUBLIC_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx +STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f18189e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.astro +data +*.log +.DS_Store +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b5ee3ac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-slim +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ ca-certificates wget && rm -rf /var/lib/apt/lists/* +COPY package*.json ./ +RUN npm install --no-audit --no-fund +COPY . . +RUN npm run build +ENV HOST=0.0.0.0 PORT=4321 NODE_ENV=production DB_PATH=/data/hdc.db +RUN mkdir -p /data +EXPOSE 4321 +HEALTHCHECK --interval=15s --timeout=5s --start-period=25s --retries=5 CMD wget -qO- http://127.0.0.1:4321/ >/dev/null 2>&1 || exit 1 +CMD ["node","./dist/server/entry.mjs"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bffbe82 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Heidrich Digital + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MIRROR.md b/MIRROR.md new file mode 100644 index 0000000..677f468 --- /dev/null +++ b/MIRROR.md @@ -0,0 +1,28 @@ +# Spiegelung nach GitHub + +Dieses Repo lebt primär auf Gitea (`git.heidrich-digital.de/till/hd-commerce`). So spiegelst du es nach GitHub. + +## Option A — Gitea Push-Mirror (empfohlen) + +1. In GitHub ein leeres Repo `hd-commerce` anlegen (ohne README/License). +2. In GitHub einen **Personal Access Token** (Scope `repo`) erstellen. +3. In Gitea: **Repo → Einstellungen → Mirror-Einstellungen → Push-Mirror hinzufügen** + - Git-Remote-URL: `https://github.com//hd-commerce.git` + - Authorization: GitHub-Username + PAT als Passwort + - Intervall z. B. `8h`, „Beim Push synchronisieren" aktivieren. +4. „Jetzt synchronisieren" — ab jetzt pusht Gitea automatisch nach GitHub. + +## Option B — manuell per zweitem Remote + +```bash +git remote add github https://github.com//hd-commerce.git +git push github main +``` + +Für künftige Pushes auf beide Remotes: + +```bash +git remote set-url --add --push origin https://git.heidrich-digital.de/till/hd-commerce.git +git remote set-url --add --push origin https://github.com//hd-commerce.git +git push origin main # geht jetzt an beide +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c2cba9 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# hd-commerce + +**hd-commerce** ist ein eigenständiges, brand-neutrales E-Commerce-Backend von Heidrich Digital: eine wiederverwendbare Astro-SSR-Anwendung mit Commerce-Engine (SQLite), Shopify-artigem Admin, JSON-API und einem schlanken, neutralen Demo-Storefront. + +Die mitgelieferte Demo-Instanz heißt **„Brittas Nähkiste"** (Kurzwaren/Nähbedarf) und dient nur als Beispiel. Name, Akzentfarbe, Texte und Logo-Wortmarke sind über die Einstellungen frei anpassbar — derselbe Code läuft für beliebige Shops. + +## Features + +- **Storefront** (hell, editorial, neutral): Startseite mit Announcement-Bar, Slider, Kategorien, Featured-Produkten und Newsletter; Shop-Katalog mit Kategorie-Filter; Produktdetailseiten; Warenkorb (localStorage); Checkout; Rechts-/Inhaltsseiten aus der DB. +- **Admin** (Shopify-Stil): Dashboard mit KPIs & Funnel, Bestellungen mit Status-Workflow, Produkt-Editor (CRUD), Kunden, eigene Analytics (First-Party, Chart.js), Marketing (Popups & Announcement-Bar), Inhalte (Seiten, Slider, Medien-Upload) und Einstellungen. +- **Engine**: synchron via `better-sqlite3` (WAL), automatisches Seeding beim ersten Start. +- **API**: `/api/checkout` (Stripe Hosted Checkout oder Demo-Fallback), `/api/track`, `/api/subscribe`, `/api/upload`, `/uploads/[file]`. +- **First-Party-Analytics**: eigene `events`-Tabelle, kein externer Dienst, keine personenbezogenen Rohdaten (Session = täglich rollender Hash aus IP+UA+Tag). +- **Branding konfigurierbar**: Shop-Name, Akzentfarbe, Währung u. a. in einer `settings`-Tabelle; Akzentfarbe wird als CSS-Variable in Storefront **und** Admin injiziert. +- **Self-hosted Fonts** (Fraunces + Public Sans), kein Google-CDN. + +## Umgebungsvariablen (ENV) + +| Variable | Beschreibung | Default | +|---|---|---| +| `DB_PATH` | Pfad zur SQLite-Datenbank (wird angelegt) | `./data/hdc.db` | +| `ADMIN_USER` | Basic-Auth-Benutzer für `/admin` | `admin` | +| `ADMIN_PASS` | Basic-Auth-Passwort für `/admin` | `admin` | +| `STRIPE_PUBLIC_KEY` | Stripe Publishable Key (optional) | – | +| `STRIPE_SECRET_KEY` | Stripe Secret Key (`sk_test_…`/`sk_live_…`). Ohne echten Key läuft der Demo-Checkout. | – | + +Siehe `.env.example`. + +## Lokal starten + +```bash +npm install +npm run dev # Entwicklungsserver auf http://localhost:4321 +# oder produktiv: +npm run build +node ./dist/server/entry.mjs +``` + +Storefront: `/` · Admin: `/admin` (Standard `admin` / `admin`). + +## Docker / Coolify + +```bash +docker build -t hd-commerce . +docker run -p 4321:4321 -v hdc-data:/data \ + -e ADMIN_USER=admin -e ADMIN_PASS=geheim hd-commerce +``` + +Das `Dockerfile` (node:22-slim) baut `better-sqlite3` nativ, legt `/data` an und setzt `DB_PATH=/data/hdc.db`. Auf Coolify ein persistentes Volume auf `/data` mounten, damit Datenbank und Uploads erhalten bleiben. HEALTHCHECK prüft `/`. + +## Datenmodell + +`settings`, `products`, `orders`, `customers`, `slides`, `pages`, `popups`, `subscribers`, `events`, `media` — alles seed-bar und im Admin pflegbar. + +--- + +> **Hinweis:** „Brittas Nähkiste" ist nur die mitgelieferte Demo-Instanz. Brand (Name, Farben, Texte) ist über **Admin → Einstellungen** anpassbar. + +Lizenz: MIT (siehe `LICENSE`). diff --git a/astro.config.mjs b/astro.config.mjs new file mode 100644 index 0000000..ca5411f --- /dev/null +++ b/astro.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig } from 'astro/config'; +import node from '@astrojs/node'; + +export default defineConfig({ + output: 'server', + adapter: node({ mode: 'standalone' }), + server: { host: '0.0.0.0', port: 4321 }, + security: { checkOrigin: false }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..c072933 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "hd-commerce", + "type": "module", + "version": "1.0.0", + "private": true, + "description": "hd-commerce — neutrales SQLite-Commerce-Backend (Admin + API + Demo-Storefront)", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "start": "node ./dist/server/entry.mjs" + }, + "dependencies": { + "astro": "^5.6.0", + "@astrojs/node": "^9.1.3", + "better-sqlite3": "^11.8.1", + "stripe": "^17.5.0", + "@fontsource-variable/fraunces": "^5.1.0", + "@fontsource-variable/public-sans": "^5.1.0" + } +} diff --git a/public/popups.js b/public/popups.js new file mode 100644 index 0000000..0cc4fd8 --- /dev/null +++ b/public/popups.js @@ -0,0 +1,75 @@ +/* hd-commerce — Popup-Engine (vanilla). Frequenz-Cap via localStorage. */ +(function () { + var root = document.getElementById('popupRoot'); + if (!root) return; + var popups; + try { popups = JSON.parse(root.getAttribute('data-popups') || '[]'); } catch (e) { return; } + if (!popups || !popups.length) return; + + function seen(p) { + var k = 'hdc_popup_' + p.id; + var v = localStorage.getItem(k); + if (!v) return false; + if (p.freq === 'always') return false; + if (p.freq === 'session') return sessionStorage.getItem('hdc_ps_' + p.id) === '1'; + if (p.freq === 'days7') { return (Date.now() - parseInt(v, 10)) < 7 * 864e5; } + return true; + } + function mark(p) { + localStorage.setItem('hdc_popup_' + p.id, String(Date.now())); + if (p.freq === 'session') sessionStorage.setItem('hdc_ps_' + p.id, '1'); + } + function build(p) { + var ov = document.createElement('div'); + ov.className = 'hdc-popup-overlay'; + var isNl = p.type === 'newsletter'; + ov.innerHTML = + '
' + + '' + + (p.image ? '' : '') + + '

' + (p.headline || '') + '

' + + '

' + (p.body || '') + '

' + + (isNl + ? '
' + : (p.cta_url ? '' + (p.cta_text || 'Mehr') + '' : '')) + + '
'; + document.body.appendChild(ov); + requestAnimationFrame(function () { ov.classList.add('show'); }); + function close() { ov.classList.remove('show'); mark(p); setTimeout(function () { ov.remove(); }, 320); } + ov.querySelector('.px').addEventListener('click', close); + ov.addEventListener('click', function (e) { if (e.target === ov) close(); }); + var form = ov.querySelector('form'); + if (form) { + form.addEventListener('submit', function (e) { + e.preventDefault(); + var email = form.querySelector('input').value; + var msg = form.querySelector('.nl-msg'); + fetch('/api/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email, source: 'popup' }) }) + .then(function (r) { return r.json(); }) + .then(function () { msg.textContent = 'Danke! Schau in dein Postfach.'; setTimeout(close, 1600); }) + .catch(function () { msg.textContent = 'Bitte später erneut versuchen.'; }); + }); + } + } + function arm(p) { + if (seen(p)) return; + var trig = p.trigger || 'delay'; + var val = parseInt(p.trigger_value, 10) || 0; + if (trig === 'delay') { setTimeout(function () { build(p); }, Math.max(0, val) * 1000); } + else if (trig === 'scroll') { + var fn = function () { + var sc = (window.scrollY + window.innerHeight) / document.body.scrollHeight * 100; + if (sc >= (val || 50)) { window.removeEventListener('scroll', fn); build(p); } + }; + window.addEventListener('scroll', fn, { passive: true }); + } else if (trig === 'exit') { + var fired = false; + document.addEventListener('mouseout', function (e) { + if (!fired && e.clientY <= 0 && !e.relatedTarget) { fired = true; build(p); } + }); + setTimeout(function () { if (!fired) { fired = true; build(p); } }, 25000); // mobile-fallback + } + } + popups.forEach(arm); +})(); diff --git a/public/shop.js b/public/shop.js new file mode 100644 index 0000000..723abe4 --- /dev/null +++ b/public/shop.js @@ -0,0 +1,62 @@ +/* hd-commerce — Storefront Cart (vanilla, localStorage) */ +(function () { + var KEY = 'hdc_cart'; + function read() { try { return JSON.parse(localStorage.getItem(KEY) || '[]'); } catch (e) { return []; } } + function write(c) { localStorage.setItem(KEY, JSON.stringify(c)); updateBadge(); } + function count() { return read().reduce(function (s, i) { return s + (i.qty || 1); }, 0); } + function updateBadge() { + var b = document.getElementById('cartBadge'); + if (!b) return; + var n = count(); + b.textContent = n; + b.classList.toggle('show', n > 0); + } + function track(type, value, meta) { + try { + fetch('/api/track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: type, path: location.pathname, value_cents: value || 0, meta: meta || {} }) }); + } catch (e) {} + } + function add(item) { + var c = read(); + var ex = c.find(function (i) { return i.slug === item.slug && i.size === item.size; }); + if (ex) ex.qty += item.qty || 1; else c.push(item); + write(c); + track('add_to_cart', (item.priceCents || 0) * (item.qty || 1), { slug: item.slug }); + toast(item.name + ' wurde hinzugefügt'); + } + function toast(msg) { + var t = document.createElement('div'); + t.textContent = msg; + t.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--ink);color:#fff;padding:12px 22px;border-radius:999px;font-size:14px;font-weight:600;z-index:200;box-shadow:0 8px 30px rgba(0,0,0,.2);transition:.3s;opacity:0'; + document.body.appendChild(t); + requestAnimationFrame(function () { t.style.opacity = '1'; }); + setTimeout(function () { t.style.opacity = '0'; setTimeout(function () { t.remove(); }, 320); }, 2000); + } + window.HDC = { + read: read, write: write, count: count, add: add, track: track, + remove: function (idx) { var c = read(); c.splice(idx, 1); write(c); }, + setQty: function (idx, q) { var c = read(); if (c[idx]) { c[idx].qty = Math.max(1, q); write(c); } }, + clear: function () { write([]); }, + subtotal: function () { return read().reduce(function (s, i) { return s + (i.priceCents || 0) * (i.qty || 1); }, 0); } + }; + document.addEventListener('DOMContentLoaded', function () { + updateBadge(); + document.querySelectorAll('[data-add-to-cart]').forEach(function (btn) { + btn.addEventListener('click', function () { + var p = JSON.parse(btn.getAttribute('data-product') || '{}'); + var sizeSel = document.querySelector('.size-chip.active'); + var size = sizeSel ? sizeSel.getAttribute('data-size') : (p.sizes && p.sizes[0]) || 'One Size'; + add({ slug: p.slug, name: p.name, size: size, priceCents: p.priceCents, image: p.image, qty: 1 }); + }); + }); + document.querySelectorAll('.size-chip').forEach(function (chip) { + chip.addEventListener('click', function () { + document.querySelectorAll('.size-chip').forEach(function (c) { c.classList.remove('active'); }); + chip.classList.add('active'); + }); + }); + var pv = document.getElementById('pdpData'); + if (pv) track('product_view', 0, { slug: pv.getAttribute('data-slug') }); + }); +})(); diff --git a/public/slider.js b/public/slider.js new file mode 100644 index 0000000..96bb82f --- /dev/null +++ b/public/slider.js @@ -0,0 +1,16 @@ +(function () { + var track = document.getElementById('hdcSlides'); + if (!track) return; + var slides = track.children, n = slides.length, i = 0; + var dots = document.querySelectorAll('#hdcDots button'); + function go(x) { + i = (x + n) % n; + track.style.transform = 'translateX(-' + (i * 100) + '%)'; + dots.forEach(function (d, k) { d.classList.toggle('active', k === i); }); + } + var prev = document.getElementById('hdcPrev'), next = document.getElementById('hdcNext'); + if (prev) prev.addEventListener('click', function () { go(i - 1); }); + if (next) next.addEventListener('click', function () { go(i + 1); }); + dots.forEach(function (d) { d.addEventListener('click', function () { go(+d.getAttribute('data-idx')); }); }); + if (n > 1) setInterval(function () { go(i + 1); }, 6000); +})(); diff --git a/src/layouts/Admin.astro b/src/layouts/Admin.astro new file mode 100644 index 0000000..31bb7de --- /dev/null +++ b/src/layouts/Admin.astro @@ -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' }, +]; +--- + + + + + + + {title} · {shopName} Admin + + + +
+ + +
+
+
+
+ Admin + {crumbs.map((c) => (<>/{c.href ? {c.label} : {c.label}}))} +
+
{title}
+
+
+
+
+
+
+ + diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro new file mode 100644 index 0000000..fc15013 --- /dev/null +++ b/src/layouts/Base.astro @@ -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); +--- + + + + + + + {pageTitle} + + + + {announceActive && announceText && ( + + )} + +
+
+ {shopName} + + +
+
+ +
+ +
+ +
+
+
+
+ {shopName} +

{tagline || `${shopName} — sorgfältig ausgewählte Produkte, schneller Versand.`}

+
+
+

Shop

+ Alle Produkte + {categories.map((c) => ({c}))} +
+
+

Service

+ Über uns + Warenkorb + {settings.shop_email && (Kontakt)} +
+
+

Rechtliches

+ {legalPages.map((p) => ({p.title}))} +
+
+
+ © {new Date().getFullYear()} {shopName}. Alle Rechte vorbehalten. + Powered by hd-commerce +
+
+
+ +
+ + + + + diff --git a/src/lib/seed.js b/src/lib/seed.js new file mode 100644 index 0000000..59849c5 --- /dev/null +++ b/src/lib/seed.js @@ -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) => `

${title}

\n

Brittas Nähkiste
Britta Mustermann
Musterstraße 3
21680 Musterort

\n

E-Mail: hallo@brittas-naehkiste.de
Telefon: 0 41 41 / 12 34 56

\n${extra}`; + +export const SEED_PAGES = [ + { slug: 'ueber-uns', title: 'Über uns', type: 'content', active: 1, sort: 1, + body: `

Willkommen in Brittas Nähkiste

\n

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.

\n

Wir lieben es, wenn aus einer Idee ein fertiges Stück wird. Dabei begleiten wir dich mit ehrlicher Beratung, fairer Auswahl und schnellem Versand.

\n

Hinweis: Brittas Nähkiste ist die mitgelieferte Demo-Instanz von hd-commerce. Inhalte, Name und Farben sind im Admin frei anpassbar.

` }, + { slug: 'impressum', title: 'Impressum', type: 'legal', active: 1, sort: 2, + body: legalBody('Impressum', `

Umsatzsteuer-ID: DE000000000
Inhaltlich verantwortlich gemäß § 18 Abs. 2 MStV: Britta Mustermann, Anschrift wie oben.

\n

Dies ist ein neutraler Platzhaltertext der Demo-Instanz.

`) }, + { slug: 'datenschutz', title: 'Datenschutz', type: 'legal', active: 1, sort: 3, + body: legalBody('Datenschutzerklärung', `

Wir nehmen den Schutz deiner Daten ernst. Personenbezogene Daten werden nur im Rahmen der Bestellabwicklung verarbeitet. Die First-Party-Statistik erhebt keine personenbezogenen Rohdaten.

\n

Dies ist ein neutraler Platzhaltertext der Demo-Instanz und ersetzt keine Rechtsberatung.

`) }, + { slug: 'agb', title: 'AGB', type: 'legal', active: 1, sort: 4, + body: legalBody('Allgemeine Geschäftsbedingungen', `

Es gelten die gesetzlichen Bestimmungen. Preise verstehen sich inkl. gesetzlicher MwSt. zzgl. Versandkosten.

\n

Dies ist ein neutraler Platzhaltertext der Demo-Instanz.

`) }, + { slug: 'widerruf', title: 'Widerrufsrecht', type: 'legal', active: 1, sort: 5, + body: legalBody('Widerrufsbelehrung', `

Verbraucher:innen haben ein vierzehntägiges Widerrufsrecht. Meterware, die nach Kundenwunsch zugeschnitten wurde, kann vom Widerruf ausgeschlossen sein.

\n

Dies ist ein neutraler Platzhaltertext der Demo-Instanz.

`) }, +]; + +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 }, +]; diff --git a/src/lib/store-sqlite.js b/src/lib/store-sqlite.js new file mode 100644 index 0000000..2e299b1 --- /dev/null +++ b/src/lib/store-sqlite.js @@ -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=? AND created_at -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(); +} diff --git a/src/pages/admin/analytics/index.astro b/src/pages/admin/analytics/index.astro new file mode 100644 index 0000000..bd01553 --- /dev/null +++ b/src/pages/admin/analytics/index.astro @@ -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); +--- + +
+ {[7, 30, 90].map((d) => ({d} Tage))} +
+
+
+ {kpis.map((k) => (
{k.label}
{k.val}
letzte {days} Tage
))} +
+ +
+
Aufrufe & Umsatz (Zeitreihe)
+
+
+ +
+
+
Conversion-Funnel
+
+
+ {a.funnel.map((f) => ( +
+
{f.label}
+
{f.value}
+
+ ))} +
+
+
+ +
+
Umsatz pro Quelle
+
+ + + + {a.bySource.map((s) => ( + + ))} + +
QuelleBesucherKäufeUmsatz
{s.src}
{s.visitors}{s.purchases}{formatPrice(s.revenue)}
+
+
+
+ +
+
Top-Produkte (Ansichten → Käufe)
+
+ + + + {a.topProducts.length === 0 ? () : + a.topProducts.map((p) => ( + + ))} + +
ProduktAnsichtenKäufeConversion
Noch keine Produktdaten
{p.name}{p.views}{p.buys}= 5 ? 'green' : 'gray'}`}>{p.rate.toFixed(1)} %
+
+
+
+ + + +
diff --git a/src/pages/admin/bestellungen/[id].astro b/src/pages/admin/bestellungen/[id].astro new file mode 100644 index 0000000..3746bc6 --- /dev/null +++ b/src/pages/admin/bestellungen/[id].astro @@ -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']]; +--- + +
+ {flash &&
✓ {flash}
} +
+
+
Artikel
+
+ + + + {order.items.map((i) => ( + + ))} + +
ProduktVarianteMengeEinzelpreisSumme
{i.name}{i.size || '—'}{i.qty}{formatPrice(i.priceCents)}{formatPrice(i.priceCents * i.qty)}
+
+
Gesamt{formatPrice(order.total_cents)}
+
+ +
+
+
Status
+
{(statusMap[order.status]||['',order.status])[1]}
+
+ + +
+
+
+
Kunde
+

{order.customer_name || '—'}

+

{order.email}

+
Lieferadresse
+

{order.address || '—'}

+
Bestellt am
+

{fmtDate(order.created_at)}

+
+
+
+
+
diff --git a/src/pages/admin/bestellungen/index.astro b/src/pages/admin/bestellungen/index.astro new file mode 100644 index 0000000..66291d2 --- /dev/null +++ b/src/pages/admin/bestellungen/index.astro @@ -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' }); +--- + +
+
+ + + + {orders.length === 0 ? () : + orders.map((o) => ( + + + + + + + + + ))} + +
BestellungDatumKundeArtikelStatusBetrag
Noch keine Bestellungen
{o.number}{fmtDate(o.created_at)}{o.customer_name || '—'}
{o.email}
{o.items.reduce((s, i) => s + (i.qty || 1), 0)} Stk.{(statusMap[o.status]||['',o.status])[1]}{formatPrice(o.total_cents)}
+
+
+
diff --git a/src/pages/admin/einstellungen/index.astro b/src/pages/admin/einstellungen/index.astro new file mode 100644 index 0000000..de082dd --- /dev/null +++ b/src/pages/admin/einstellungen/index.astro @@ -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']; +--- + +
+ {flash &&
✓ {flash}
} +
+
+
+
Shop
+
+
+
+
+ +
+
Branding
+
+
+
+
+
Die Akzentfarbe wird im Storefront und im Admin als CSS-Variable injiziert.
+
+ +
+
Verkauf
+
+
+
+
+
+ +
+ +
+
+
Zahlung (Stripe)
+

{stripeMode}

+

{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).'}

+

Konfiguration über ENV: STRIPE_SECRET_KEY, STRIPE_PUBLIC_KEY.

+
+
+
Analytics
+

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.

+
+
+
System
+

Datenbank: SQLite (DB_PATH). Admin-Zugang über ADMIN_USER / ADMIN_PASS.

+
+
+
+
+
diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro new file mode 100644 index 0000000..d1235ff --- /dev/null +++ b/src/pages/admin/index.astro @@ -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' }, +]; +--- + + + Produkt +
+
+ {kpis.map((k) => ( +
{k.label}
{k.val}
{k.sub}
+ ))} +
+ +
+
First-Party-Funnel (30 Tage)Details
+
+
+
{d.funnelMini.views}
Aufrufe
+
+
{d.funnelMini.cart}
In den Korb
+
+
{d.funnelMini.buy}
Kauf
+
+
+
+ +
+
+
Neueste BestellungenAlle
+
+ + + + {d.recentOrders.map((o) => ( + + + + + + + ))} + +
BestellungKundeStatusBetrag
{o.number}
{fmtDate(o.created_at)}
{o.customer_name || '—'}{(statusMap[o.status]||['',o.status])[1]}{formatPrice(o.total_cents)}
+
+
+ +
+
Geringer Bestand
+
+ + + + {d.lowStock.length === 0 ? () : + d.lowStock.map((p) => ( + + + + + ))} + +
ProduktBestand
Alles gut bestückt 👍
{p.cardImage && }{p.shortName || p.name}
{p.stock}
+
+
+
+
+
diff --git a/src/pages/admin/inhalte/index.astro b/src/pages/admin/inhalte/index.astro new file mode 100644 index 0000000..13ce566 --- /dev/null +++ b/src/pages/admin/inhalte/index.astro @@ -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']]; +--- + +
+ {flash &&
✓ {flash}
} +
+ {tabs.map(([v, l]) => ({l}))} +
+ + {tab === 'pages' && ( +
+
+
Seiten
+
+ + + + {pages.map((p) => ( + + + + + + + + ))} + +
TitelSlugTypStatus
{p.title}/seite/{p.slug}{p.type === 'legal' ? 'Rechtstext' : 'Inhalt'}{p.active ? Aktiv : Aus} + Bearbeiten +
+
+
+
+
+
{ep ? 'Seite bearbeiten' : 'Seite anlegen'}
+
+ + {ep && } +
+
+
+
+
+
+
+
+
+
+ + {ep && Abbrechen} +
+
+
+ )} + + {tab === 'slider' && ( +
+
+
Slides
+
+ + + + {slides.map((s) => ( + + + + + + + + ))} + +
BildHeadlineReihenf.Status
{s.image && }
{s.headline}
{s.subline}
{s.sort}{s.active ? Aktiv : Aus} + Bearbeiten +
+
+
+
+
+
{es ? 'Slide bearbeiten' : 'Slide anlegen'}
+
+ + {es && } +
+
+
+
+
+
+
+
+ + {es && Abbrechen} +
+
+
+ )} + + {tab === 'media' && ( +
+
+
Medien hochladen
+ +
+
+
+
Medienbibliothek
+
+ {media.length === 0 ? (
Noch keine Medien hochgeladen
) : ( +
+ {media.map((m) => ( +
+ {m.filename} +
{Math.round((m.size || 0) / 1024)} KB
+
+ ))} +
+ )} +
+
+ +
+ )} +
+
diff --git a/src/pages/admin/kunden/index.astro b/src/pages/admin/kunden/index.astro new file mode 100644 index 0000000..92095da --- /dev/null +++ b/src/pages/admin/kunden/index.astro @@ -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' }) : '—'; +--- + +
+
+ + + + {customers.length === 0 ? () : + customers.map((c) => ( + + + + + + + + + ))} + +
NameE-MailOrtBestellungenUmsatzSeit
Noch keine Kunden
{c.name || '—'}{c.email}{c.city || '—'}{c.orders_count}{formatPrice(c.total_spent_cents)}{fmtDate(c.created_at)}
+
+
+
diff --git a/src/pages/admin/marketing/index.astro b/src/pages/admin/marketing/index.astro new file mode 100644 index 0000000..a8140a3 --- /dev/null +++ b/src/pages/admin/marketing/index.astro @@ -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']]; +--- + +
+ {flash &&
✓ {flash}
} + +
+
Announcement-Bar
+
+ +
+
+
+
+
+
+ +
+
+
Popups+ Neu
+
+ + + + {popups.length === 0 ? () : + popups.map((pp) => ( + + + + + + + + + ))} + +
TitelTypTriggerPfadStatus
Keine Popups
{pp.title}{pp.type}{pp.trigger}{pp.target_path}{pp.active ? Aktiv : Inaktiv} + Bearbeiten +
+
+
+
+ +
+
{editing ? 'Popup bearbeiten' : 'Popup anlegen'}
+
+ + {editing && } +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {editing && Abbrechen} +
+
+
+
+
diff --git a/src/pages/admin/produkte/[id].astro b/src/pages/admin/produkte/[id].astro new file mode 100644 index 0000000..aad72a0 --- /dev/null +++ b/src/pages/admin/produkte/[id].astro @@ -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('.', ',') : ''; +--- + +
+ {flash &&
✓ {flash}
} +
+
+
+
+
+
+
+
+
+
+ +
+
Medien
+
+
+
+ +
+
Eigenschaften
+
+
+
+
+
+
+
+ +
+
+ + Zurück +
+
+
+
+ + {cats.map((c) => ( +
+
+
+
+
+
+ +
+
+
+
+
diff --git a/src/pages/admin/produkte/index.astro b/src/pages/admin/produkte/index.astro new file mode 100644 index 0000000..2d917de --- /dev/null +++ b/src/pages/admin/produkte/index.astro @@ -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(); +--- + + + Produkt anlegen +
+
+ + + + {products.length === 0 ? () : + products.map((p) => ( + + + + + + + + + ))} + +
ProduktKategorieBestandFeaturedPreis
Noch keine Produkte
+
{p.cardImage && }
{p.shortName || p.name}
{p.badge && {p.badge}}
+
{p.category || '—'}{p.stock == null ? : {p.stock}}{p.featured ? Ja : }{formatPrice(p.priceCents)} + Bearbeiten +
+ + +
+
+
+
+
diff --git a/src/pages/api/checkout.js b/src/pages/api/checkout.js new file mode 100644 index 0000000..886696c --- /dev/null +++ b/src/pages/api/checkout.js @@ -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` }); +} diff --git a/src/pages/api/subscribe.js b/src/pages/api/subscribe.js new file mode 100644 index 0000000..03211a3 --- /dev/null +++ b/src/pages/api/subscribe.js @@ -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 }); +} diff --git a/src/pages/api/track.js b/src/pages/api/track.js new file mode 100644 index 0000000..66cbbc1 --- /dev/null +++ b/src/pages/api/track.js @@ -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 }); +} diff --git a/src/pages/api/upload.js b/src/pages/api/upload.js new file mode 100644 index 0000000..0010d6a --- /dev/null +++ b/src/pages/api/upload.js @@ -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); + } +} diff --git a/src/pages/bestellung-erfolgreich.astro b/src/pages/bestellung-erfolgreich.astro new file mode 100644 index 0000000..76b4979 --- /dev/null +++ b/src/pages/bestellung-erfolgreich.astro @@ -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'; +--- + +
+
+
+ +
+

Vielen Dank für deine Bestellung!

+ {order &&

Deine Bestellnummer: {order}

} +

Wir haben deine Bestellung erhalten und melden uns per E-Mail mit den Versanddetails.

+ {demo &&

Demo-Hinweis: Diese Bestellung wurde ohne echte Zahlung im Demo-Modus angelegt.

} + Weiter einkaufen +
+
+ diff --git a/src/pages/checkout.astro b/src/pages/checkout.astro new file mode 100644 index 0000000..5a3fa44 --- /dev/null +++ b/src/pages/checkout.astro @@ -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()); +--- + +
+

Zur Kasse

+ {!hasStripe && (

Demo-Modus: Es ist kein echter Stripe-Schlüssel hinterlegt — die Bestellung wird ohne Zahlung abgeschlossen.

)} +
+
+

Kontakt & Lieferadresse

+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + + diff --git a/src/pages/index.astro b/src/pages/index.astro new file mode 100644 index 0000000..045b3f5 --- /dev/null +++ b/src/pages/index.astro @@ -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 || ''; +--- + +
+ {slides.length > 0 && ( +
+
+ {slides.map((s) => ( +
+ {s.image && {s.headline}} +
+

{s.headline}

+ {s.subline &&

{s.subline}

} + {s.link && Jetzt entdecken} +
+
+ ))} +
+ {slides.length > 1 && ( + <> + + +
+ {slides.map((_, i) => ())} +
+ + )} +
+ )} +
+ + {categories.length > 0 && ( +
+
+
+
+ Sortiment +

Nach Kategorie stöbern

+
+
+
+ {categories.map((c) => ( + + {c} + {catCount(c)} Artikel + + ))} +
+
+
+ )} + + {featured.length > 0 && ( +
+
+
+
+ Empfehlungen +

Beliebt bei {shopName}

+

Handverlesene Lieblinge für dein nächstes Projekt.

+
+ Alle Produkte +
+ +
+
+ )} + +
+
+ +
+
+ + + + diff --git a/src/pages/produkt/[slug].astro b/src/pages/produkt/[slug].astro new file mode 100644 index 0000000..b2c287b --- /dev/null +++ b/src/pages/produkt/[slug].astro @@ -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 }; +--- + +
+
+ + +
+
+
{product.category}
+

{product.name}

+
{formatPrice(product.priceCents)}
+ {product.desc &&

{product.desc}

} + + {product.sizes && product.sizes.length > 0 && product.sizes[0] !== 'One Size' && ( + <> +
Variante
+
+ {product.sizes.map((s, i) => ())} +
+ + )} + + + + {product.features && product.features.length > 0 && ( +
    {product.features.map((f) => (
  • {f}
  • ))}
+ )} + +
+ {product.material &&
Material: {product.material}
} + {product.stock != null &&
Verfügbarkeit: {product.stock > 0 ? `${product.stock} auf Lager` : 'Ausverkauft'}
} +
+
+
+ + {related.length > 0 && ( +
+
Passt dazu

Ähnliche Produkte

+ +
+ )} +
+ + + diff --git a/src/pages/seite/[slug].astro b/src/pages/seite/[slug].astro new file mode 100644 index 0000000..0e978a5 --- /dev/null +++ b/src/pages/seite/[slug].astro @@ -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('/'); +--- + +
+
+

{page.title}

+
+
+
+ diff --git a/src/pages/shop.astro b/src/pages/shop.astro new file mode 100644 index 0000000..f13b7b9 --- /dev/null +++ b/src/pages/shop.astro @@ -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; +--- + +
+
+ Sortiment +

{activeCat || 'Alle Produkte'}

+
+ Alle + {categories.map((c) => ( + {c} + ))} +
+
+
+ +
+
+ {filtered.length === 0 ? ( +

Keine Produkte gefunden

In dieser Kategorie ist aktuell nichts verfügbar.

+ ) : ( + + )} +
+
+ diff --git a/src/pages/uploads/[...file].js b/src/pages/uploads/[...file].js new file mode 100644 index 0000000..85e6eb5 --- /dev/null +++ b/src/pages/uploads/[...file].js @@ -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' } }); +} diff --git a/src/pages/warenkorb.astro b/src/pages/warenkorb.astro new file mode 100644 index 0000000..494b2cb --- /dev/null +++ b/src/pages/warenkorb.astro @@ -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'); +--- + +
+

Warenkorb

+
+
+ + + diff --git a/src/styles/admin.css b/src/styles/admin.css new file mode 100644 index 0000000..6883b64 --- /dev/null +++ b/src/styles/admin.css @@ -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}} diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..30ff863 --- /dev/null +++ b/src/styles/global.css @@ -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}} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1addde5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { "strictNullChecks": true } +}