Demoshop 1 — NORDLICHT Eigenbau-Shop (Astro SSR + SQLite + Admin-Backend, Katalog, Warenkorb, Journal, Rechtstexte)

This commit is contained in:
2026-06-16 03:46:59 +00:00
commit 253e133bba
21 changed files with 6795 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.astro
.git
data
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.astro
data
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ ca-certificates wget && rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm install --no-audit --no-fund
COPY . .
RUN npm run build
ENV HOST=0.0.0.0 PORT=4321 NODE_ENV=production DB_PATH=/data/shop.db
RUN mkdir -p /data
EXPOSE 4321
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 CMD wget -qO- http://127.0.0.1:4321/ >/dev/null 2>&1 || exit 1
CMD ["node","./dist/server/entry.mjs"]
+7
View File
@@ -0,0 +1,7 @@
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 }
});
+6128
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "demo-eigenbau-shop",
"type": "module",
"version": "1.0.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"start": "node ./dist/server/entry.mjs"
},
"dependencies": {
"astro": "^4.16.18",
"@astrojs/node": "^8.3.4",
"better-sqlite3": "^11.8.1",
"@fontsource-variable/fraunces": "^5.1.0",
"@fontsource-variable/public-sans": "^5.1.0"
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />
+151
View File
@@ -0,0 +1,151 @@
---
import '@fontsource-variable/fraunces';
import '@fontsource-variable/public-sans';
const { title = 'NORDLICHT', admin = false } = Astro.props;
---
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} · NORDLICHT Concept Store</title>
<meta name="robots" content="noindex" />
<style is:global>
:root{
--petrol: oklch(0.50 0.075 205);--petrol-deep: oklch(0.38 0.07 212);
--light: oklch(0.975 0.008 200);--soft: oklch(0.93 0.018 195);
--slate: oklch(0.55 0.02 230);--graphit: oklch(0.33 0.012 240);
--ink: oklch(0.21 0.012 250);--weiss: oklch(0.995 0 0);
--line: oklch(0.88 0.012 215);--radius:14px;--wrap:1140px;
}
*{box-sizing:border-box}html{scroll-behavior:smooth}
body{margin:0;background:var(--light);color:var(--ink);font-family:'Public Sans Variable',system-ui,sans-serif;line-height:1.6;-webkit-font-smoothing:antialiased}
h1,h2,h3{font-family:'Fraunces Variable',Georgia,serif;font-weight:560;line-height:1.12;letter-spacing:-0.01em;margin:0 0 .4em}
a{color:inherit;text-decoration:none}img{display:block;max-width:100%}
.wrap{max-width:var(--wrap);margin-inline:auto;padding-inline:22px}
.u{position:relative;display:inline-block}.u::after{content:"_";color:var(--petrol);font-weight:700}
.btn{display:inline-flex;align-items:center;gap:.5em;background:var(--petrol);color:var(--weiss);border:none;border-radius:999px;padding:.7em 1.3em;font:inherit;font-weight:600;cursor:pointer;transition:background .18s,transform .18s}
.btn:hover{background:var(--petrol-deep);transform:translateY(-1px)}
.btn.ghost{background:transparent;color:var(--petrol);border:1.5px solid var(--line)}
.btn.ghost:hover{border-color:var(--petrol)}
header.site{position:sticky;top:0;z-index:40;background:color-mix(in oklch,var(--light) 86%,transparent);backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}
.nav{display:flex;align-items:center;justify-content:space-between;height:68px}
.brand{font-family:'Fraunces Variable',serif;font-size:1.3rem;font-weight:600;letter-spacing:.02em}
.brand small{display:block;font-family:'Public Sans Variable',sans-serif;font-size:.62rem;letter-spacing:.22em;text-transform:uppercase;color:var(--slate);font-weight:600}
.nav-links{display:flex;gap:1.6rem;align-items:center;font-weight:500}
.nav-links a:hover{color:var(--petrol)}
.cart-btn{position:relative;background:none;border:none;cursor:pointer;color:var(--ink);font:inherit;display:flex;align-items:center;gap:.4rem;font-weight:600}
.cart-badge{position:absolute;top:-8px;right:-10px;background:var(--petrol);color:#fff;border-radius:999px;min-width:18px;height:18px;font-size:.7rem;display:grid;place-items:center;padding:0 4px}
.drawer-bg{position:fixed;inset:0;background:oklch(0.2 0.02 250/.45);opacity:0;pointer-events:none;transition:opacity .25s;z-index:50}
.drawer-bg.open{opacity:1;pointer-events:auto}
.drawer{position:fixed;top:0;right:0;height:100%;width:min(420px,92vw);background:var(--weiss);z-index:60;transform:translateX(100%);transition:transform .28s ease;display:flex;flex-direction:column;box-shadow:-12px 0 40px oklch(0.2 0.02 250/.18)}
.drawer.open{transform:none}
.drawer header{display:flex;justify-content:space-between;align-items:center;padding:20px;border-bottom:1px solid var(--line)}
.drawer h3{margin:0;font-size:1.15rem}.drawer .items{flex:1;overflow:auto;padding:8px 20px}
.ci{display:flex;gap:12px;padding:14px 0;border-bottom:1px solid var(--line)}
.ci img{width:62px;height:62px;object-fit:cover;border-radius:10px;flex:none}
.ci .meta{flex:1;min-width:0}.ci .nm{font-weight:600;font-size:.92rem}
.ci .qty{display:flex;align-items:center;gap:8px;margin-top:6px}
.ci .qty button{width:26px;height:26px;border:1px solid var(--line);background:var(--light);border-radius:7px;cursor:pointer;font-size:1rem;line-height:1}
.ci .rm{background:none;border:none;color:var(--slate);cursor:pointer;font-size:.78rem;text-decoration:underline}
.drawer footer{padding:18px 20px;border-top:1px solid var(--line)}
.sumline{display:flex;justify-content:space-between;margin-bottom:4px;color:var(--graphit);font-size:.9rem}
.sumline.total{color:var(--ink);font-weight:700;font-size:1.1rem;margin-top:8px}
.empty{color:var(--slate);text-align:center;padding:40px 0}
.close{background:none;border:none;font-size:1.5rem;cursor:pointer;color:var(--slate);line-height:1}
footer.site{margin-top:80px;background:var(--graphit);color:oklch(0.9 0.01 220);padding:54px 0 30px}
footer.site a{color:oklch(0.9 0.01 220)}footer.site a:hover{color:#fff}
.fgrid{display:grid;grid-template-columns:2fr 1fr 1fr;gap:32px}
footer h4{font-family:'Public Sans Variable',sans-serif;text-transform:uppercase;letter-spacing:.12em;font-size:.72rem;color:oklch(0.72 0.02 220);margin:0 0 12px}
.fcol a{display:block;padding:3px 0;font-size:.92rem}
.fnote{border-top:1px solid oklch(0.45 0.01 240);margin-top:34px;padding-top:18px;font-size:.8rem;color:oklch(0.72 0.02 220);display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px}
.demo-pill{display:inline-block;background:var(--petrol);color:#fff;font-size:.7rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;padding:3px 10px;border-radius:999px}
.legal{max-width:740px;padding-top:30px;padding-bottom:20px}
.legal h1{font-size:clamp(1.8rem,3.5vw,2.6rem);margin:.3em 0 .6em}.legal h3{margin:1.4em 0 .3em;font-size:1.15rem}
.legal p{color:var(--graphit);margin:0 0 .9em}
.demo-note{display:inline-block;background:oklch(0.92 0.06 80);color:oklch(0.4 0.08 60);font-size:.8rem;font-weight:600;padding:6px 14px;border-radius:8px;margin-bottom:8px}
.legal .src{margin-top:24px;padding-top:16px;border-top:1px solid var(--line);font-size:.82rem;color:var(--slate)}
@media(max-width:760px){.fgrid{grid-template-columns:1fr}.nav-links a{display:none}}
</style>
</head>
<body>
<header class="site"><div class="wrap nav">
<a href="/" class="brand">NORDLICHT<small>Concept Store</small></a>
<nav class="nav-links">
<a href="/#shop">Shop</a>
<a href="/#journal">Journal</a>
<a href="/admin">Admin</a>
{!admin && <button class="cart-btn" id="cartOpen" aria-label="Warenkorb">Warenkorb <span class="cart-badge" id="cartBadge">0</span></button>}
</nav>
</div></header>
<main><slot /></main>
{!admin && (
<>
<div class="drawer-bg" id="drawerBg"></div>
<aside class="drawer" id="drawer" aria-label="Warenkorb">
<header><h3>Dein Warenkorb</h3><button class="close" id="cartClose">×</button></header>
<div class="items" id="cartItems"></div>
<footer>
<div class="sumline"><span>Zwischensumme</span><span id="sumNet">0,00 €</span></div>
<div class="sumline"><span>inkl. MwSt.</span><span id="sumTax">0,00 €</span></div>
<div class="sumline total"><span>Gesamt</span><span id="sumTotal">0,00 €</span></div>
<button class="btn" style="width:100%;justify-content:center;margin-top:14px" id="checkoutBtn">Zur Kasse (Demo)</button>
<p style="font-size:.74rem;color:var(--slate);text-align:center;margin:.7em 0 0">Demoshop · keine echte Bestellung</p>
</footer>
</aside>
</>
)}
<footer class="site"><div class="wrap">
<div class="fgrid">
<div>
<div class="brand" style="color:#fff">NORDLICHT<small>Concept Store</small></div>
<p style="max-width:34ch;color:oklch(0.78 0.02 220);font-size:.92rem;margin-top:14px">Design-Objekte und Wohnaccessoires aus dem Norden. Dieser Shop ist ein technischer Demonstrator.</p>
<span class="demo-pill">Demo · Eigenbau (Astro + SQLite)</span>
</div>
<div class="fcol"><h4>Shop</h4><a href="/#shop">Alle Produkte</a><a href="/#journal">Journal</a><a href="/warenkorb">Warenkorb</a><a href="/admin">Admin-Backend</a></div>
<div class="fcol"><h4>Rechtliches</h4><a href="/impressum">Impressum</a><a href="/datenschutz">Datenschutz</a><a href="/agb">AGB</a><a href="/widerruf">Widerruf</a></div>
</div>
<div class="fnote"><span>© 2026 NORDLICHT Concept Store (Demo) · Heidrich Digital</span><span>Preise inkl. MwSt., zzgl. Versand</span></div>
</div></footer>
{!admin && (
<script is:inline>
const KEY='nordlicht_cart';
const load=()=>{try{return JSON.parse(localStorage.getItem(KEY))||[]}catch{return[]}};
const save=c=>localStorage.setItem(KEY,JSON.stringify(c));
const eur=n=>new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'}).format(n);
let cart=load();
function add(p){const f=cart.find(i=>i.slug===p.slug);if(f)f.qty++;else cart.push({...p,qty:1});save(cart);render();open()}
function setQty(slug,d){const f=cart.find(i=>i.slug===slug);if(!f)return;f.qty+=d;if(f.qty<=0)cart=cart.filter(i=>i.slug!==slug);save(cart);render()}
function remove(slug){cart=cart.filter(i=>i.slug!==slug);save(cart);render()}
function totals(){let total=0,tax=0;cart.forEach(i=>{const line=i.preis*i.qty;total+=line;const r=(i.mwst||19)/100;tax+=line-(line/(1+r))});return{total,tax,net:total-tax}}
function render(){
const badge=document.getElementById('cartBadge');const n=cart.reduce((s,i)=>s+i.qty,0);if(badge)badge.textContent=n;
const wrap=document.getElementById('cartItems');
if(wrap){if(!cart.length){wrap.innerHTML='<p class="empty">Dein Warenkorb ist leer.</p>';}
else{wrap.innerHTML=cart.map(i=>`<div class="ci"><img src="${i.bild_url}" alt=""><div class="meta"><div class="nm">${i.name}</div><div style="color:var(--slate);font-size:.8rem">${eur(i.preis)} · ${i.einheit||''}</div><div class="qty"><button data-dec="${i.slug}"></button><span>${i.qty}</span><button data-inc="${i.slug}">+</button><button class="rm" data-rm="${i.slug}">entfernen</button></div></div><div style="font-weight:600">${eur(i.preis*i.qty)}</div></div>`).join('');}}
const t=totals();const set=(id,v)=>{const e=document.getElementById(id);if(e)e.textContent=eur(v)};
set('sumNet',t.net);set('sumTax',t.tax);set('sumTotal',t.total);
document.dispatchEvent(new CustomEvent('cart:changed'));
}
const drawer=document.getElementById('drawer'),bg=document.getElementById('drawerBg');
function open(){drawer&&drawer.classList.add('open');bg&&bg.classList.add('open')}
function close(){drawer&&drawer.classList.remove('open');bg&&bg.classList.remove('open')}
document.getElementById('cartOpen')?.addEventListener('click',open);
document.getElementById('cartClose')?.addEventListener('click',close);
bg?.addEventListener('click',close);
document.getElementById('checkoutBtn')?.addEventListener('click',()=>alert('Demoshop — hier würde der Checkout starten. Keine echte Bestellung.'));
document.addEventListener('click',e=>{
const a=e.target.closest('[data-add]');if(a){e.preventDefault();add(JSON.parse(a.getAttribute('data-add')));return}
const inc=e.target.closest('[data-inc]');if(inc){setQty(inc.getAttribute('data-inc'),1);return}
const dec=e.target.closest('[data-dec]');if(dec){setQty(dec.getAttribute('data-dec'),-1);return}
const rm=e.target.closest('[data-rm]');if(rm){remove(rm.getAttribute('data-rm'));return}
});
window.HK={add,setQty,remove,get:()=>cart,totals};render();
</script>
)}
</body>
</html>
+74
View File
@@ -0,0 +1,74 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
const DB_PATH = process.env.DB_PATH || './data/shop.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 products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, kategorie TEXT,
preis REAL NOT NULL DEFAULT 0, mwst INTEGER DEFAULT 19, einheit TEXT,
kurz TEXT, beschreibung TEXT, bild_url TEXT, badge TEXT, lagernd INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titel TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, datum TEXT,
teaser TEXT, inhalt TEXT, bild_url TEXT
);
`);
function seed() {
const count = db.prepare('SELECT COUNT(*) c FROM products').get().c;
if (count > 0) return;
const p = db.prepare(`INSERT INTO products (name,slug,kategorie,preis,mwst,einheit,kurz,beschreibung,bild_url,badge,lagernd)
VALUES (@name,@slug,@kategorie,@preis,@mwst,@einheit,@kurz,@beschreibung,@bild_url,@badge,@lagernd)`);
const prods = [
{name:'Steingut-Vase „Düne“',slug:'vase-duene',kategorie:'Keramik',preis:48.0,mwst:19,einheit:'H 24 cm',kurz:'Handgedrehte Vase mit matter Sandglasur.',beschreibung:'Jede Vase wird einzeln auf der Töpferscheibe gedreht. Die sandfarbene Glasur erinnert an die Nordseedünen.',bild_url:'https://picsum.photos/seed/vase-duene/900/1100',badge:'Handmade',lagernd:1},
{name:'Leinen-Kissenbezug „Salz“',slug:'kissen-salz',kategorie:'Textil',preis:32.0,mwst:19,einheit:'50×50 cm',kurz:'Gewaschenes Leinen, naturweiß.',beschreibung:'Aus europäischem Leinen, stonewashed für weichen Griff. Mit verdecktem Reißverschluss.',bild_url:'https://picsum.photos/seed/kissen-salz/900/1100',badge:'',lagernd:1},
{name:'Tischleuchte „Hafen“',slug:'leuchte-hafen',kategorie:'Licht',preis:129.0,mwst:19,einheit:'25 W, E27',kurz:'Mattschwarze Tischleuchte aus Stahl.',beschreibung:'Reduzierte Formensprache, dimmbar, mit textilummanteltem Kabel. Leuchtmittel inklusive.',bild_url:'https://picsum.photos/seed/leuchte-hafen/900/1100',badge:'Bestseller',lagernd:1},
{name:'Wolldecke „Watt“',slug:'decke-watt',kategorie:'Textil',preis:89.0,mwst:19,einheit:'130×170 cm',kurz:'Recycelte Schurwolle, grau meliert.',beschreibung:'Warm, robust und nachhaltig: gewebt aus recycelter Schurwolle in einer norddeutschen Manufaktur.',bild_url:'https://picsum.photos/seed/decke-watt/900/1100',badge:'Nachhaltig',lagernd:1},
{name:'Espressotassen-Set „Möwe“',slug:'tassen-moewe',kategorie:'Keramik',preis:39.0,mwst:19,einheit:'4er-Set',kurz:'Vier handbemalte Espressotassen.',beschreibung:'Set aus vier Tassen mit feinem Pinselstrich-Dekor. Spülmaschinenfest.',bild_url:'https://picsum.photos/seed/tassen-moewe/900/1100',badge:'',lagernd:1},
{name:'Wandregal „Steg“ (Eiche)',slug:'regal-steg',kategorie:'Möbel',preis:74.0,mwst:19,einheit:'60 cm',kurz:'Massives Eichenregal mit Stahlhaltern.',beschreibung:'Geöltes Eichenholz auf pulverbeschichteten Haltern. Inklusive Befestigungsmaterial.',bild_url:'https://picsum.photos/seed/regal-steg/900/1100',badge:'Neu',lagernd:1},
{name:'Duftkerze „Reet“',slug:'kerze-reet',kategorie:'Accessoires',preis:24.0,mwst:19,einheit:'220 g',kurz:'Sojawachs, Note von Heu &amp; Salz.',beschreibung:'Von Hand gegossene Kerze aus Sojawachs mit Holzdocht. Brenndauer ca. 45 Stunden.',bild_url:'https://picsum.photos/seed/kerze-reet/900/1100',badge:'',lagernd:0},
{name:'Karaffe „Ebbe“',slug:'karaffe-ebbe',kategorie:'Accessoires',preis:36.0,mwst:19,einheit:'1,0 l',kurz:'Mundgeblasenes Glas, klar.',beschreibung:'Schlichte Karaffe aus mundgeblasenem Glas. Jedes Stück ein Unikat mit feinen Luftblasen.',bild_url:'https://picsum.photos/seed/karaffe-ebbe/900/1100',badge:'Handmade',lagernd:1}
];
const tx = db.transaction(rows => rows.forEach(r => p.run(r)));
tx(prods);
const a = db.prepare(`INSERT INTO articles (titel,slug,datum,teaser,inhalt,bild_url) VALUES (@titel,@slug,@datum,@teaser,@inhalt,@bild_url)`);
a.run({titel:'Slow Interior: Weniger, aber besser',slug:'slow-interior',datum:'2026-05-20',teaser:'Warum langlebige Einzelstücke nachhaltiger sind als schnelle Trends.',inhalt:'Gutes Wohnen braucht keine ständige Erneuerung.\nWir setzen auf Materialien, die altern dürfen: Eiche, Leinen, Steingut.\nSo entsteht ein Zuhause mit Geschichte statt Wegwerf-Deko.',bild_url:'https://picsum.photos/seed/journal-slow/1200/700'});
a.run({titel:'Handgemacht aus dem Norden',slug:'handgemacht-norden',datum:'2026-04-30',teaser:'Ein Besuch bei den Manufakturen, mit denen wir zusammenarbeiten.',inhalt:'Hinter jedem Stück steht ein Handwerk.\nWir besuchen Töpfereien und Webereien an der Küste.\nFaire Bezahlung und kurze Wege sind für uns selbstverständlich.',bild_url:'https://picsum.photos/seed/journal-hand/1200/700'});
a.run({titel:'Farben der Küste',slug:'farben-kueste',datum:'2026-03-22',teaser:'Sand, Salz, Schiefer — unsere Palette für ein ruhiges Zuhause.',inhalt:'Die Nordsee ist unser Farbfächer.\nGedeckte Töne beruhigen den Raum und lassen sich endlos kombinieren.\nWir zeigen, wie du sie zuhause einsetzt.',bild_url:'https://picsum.photos/seed/journal-farben/1200/700'});
}
seed();
export const allProducts = () => db.prepare('SELECT * FROM products ORDER BY id').all();
export const productBySlug = (s) => db.prepare('SELECT * FROM products WHERE slug=?').get(s);
export const productById = (id) => db.prepare('SELECT * FROM products WHERE id=?').get(id);
export const allArticles = () => db.prepare('SELECT * FROM articles ORDER BY datum DESC').all();
export const articleBySlug = (s) => db.prepare('SELECT * FROM articles WHERE slug=?').get(s);
export const articleById = (id) => db.prepare('SELECT * FROM articles WHERE id=?').get(id);
export function upsertProduct(d) {
if (d.id) {
db.prepare(`UPDATE products SET name=@name,slug=@slug,kategorie=@kategorie,preis=@preis,mwst=@mwst,einheit=@einheit,kurz=@kurz,beschreibung=@beschreibung,bild_url=@bild_url,badge=@badge,lagernd=@lagernd WHERE id=@id`).run(d);
return d.id;
}
const r = db.prepare(`INSERT INTO products (name,slug,kategorie,preis,mwst,einheit,kurz,beschreibung,bild_url,badge,lagernd) VALUES (@name,@slug,@kategorie,@preis,@mwst,@einheit,@kurz,@beschreibung,@bild_url,@badge,@lagernd)`).run(d);
return r.lastInsertRowid;
}
export const deleteProduct = (id) => db.prepare('DELETE FROM products WHERE id=?').run(id);
export function upsertArticle(d) {
if (d.id) {
db.prepare(`UPDATE articles SET titel=@titel,slug=@slug,datum=@datum,teaser=@teaser,inhalt=@inhalt,bild_url=@bild_url WHERE id=@id`).run(d);
return d.id;
}
const r = db.prepare(`INSERT INTO articles (titel,slug,datum,teaser,inhalt,bild_url) VALUES (@titel,@slug,@datum,@teaser,@inhalt,@bild_url)`).run(d);
return r.lastInsertRowid;
}
export const deleteArticle = (id) => db.prepare('DELETE FROM articles WHERE id=?').run(id);
export const eur = (n) => new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'}).format(Number(n));
+21
View File
@@ -0,0 +1,21 @@
const USER = process.env.ADMIN_USER || 'admin';
const PASS = process.env.ADMIN_PASS || 'demo';
export function onRequest({ request }, next) {
const url = new URL(request.url);
if (url.pathname.startsWith('/admin')) {
const hdr = request.headers.get('authorization') || '';
if (hdr.startsWith('Basic ')) {
let decoded = '';
try { decoded = atob(hdr.slice(6)); } catch {}
const i = decoded.indexOf(':');
const u = decoded.slice(0, i), p = decoded.slice(i + 1);
if (u === USER && p === PASS) return next();
}
return new Response('Authentifizierung erforderlich', {
status: 401,
headers: { 'WWW-Authenticate': 'Basic realm="NORDLICHT Admin", charset="UTF-8"' }
});
}
return next();
}
+50
View File
@@ -0,0 +1,50 @@
---
import Base from '../../../layouts/Base.astro';
import { articleById, upsertArticle, deleteArticle } from '../../../lib/db.js';
const { id } = Astro.params;
const isNew = id === 'neu';
const slugify = (s)=>s.toLowerCase().replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss').replace(/[^a-z0-9]+/g,'-').replace(/(^-|-$)/g,'');
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData();
if (f.get('_action') === 'delete') { deleteArticle(Number(id)); return Astro.redirect('/admin?ok=Artikel+gelöscht'); }
const titel = (f.get('titel')||'').toString().trim();
const data = {
id: isNew ? null : Number(id), titel,
slug: (f.get('slug')||'').toString().trim() || slugify(titel),
datum: (f.get('datum')||'').toString().trim() || new Date().toISOString().slice(0,10),
teaser: (f.get('teaser')||'').toString().trim(),
inhalt: (f.get('inhalt')||'').toString().trim(),
bild_url: (f.get('bild_url')||'').toString().trim() || `https://picsum.photos/seed/${slugify(titel)||'artikel'}/1200/700`
};
upsertArticle(data);
return Astro.redirect('/admin?ok=Artikel+gespeichert');
}
const a = isNew ? {} : (articleById(Number(id)) || {});
---
<Base title={isNew ? 'Neuer Artikel' : 'Artikel bearbeiten'} admin={true}>
<div class="aform">
<div class="wrap" style="max-width:720px">
<a href="/admin" class="back">← Übersicht</a>
<h1>{isNew ? 'Neuer Artikel' : 'Artikel bearbeiten'}</h1>
<form method="POST">
<label>Titel<input name="titel" value={a.titel||''} required/></label>
<div class="row">
<label>Slug (optional)<input name="slug" value={a.slug||''} placeholder="automatisch"/></label>
<label>Datum<input name="datum" type="date" value={a.datum||''}/></label>
</div>
<label>Teaser<input name="teaser" value={a.teaser||''}/></label>
<label>Inhalt (Absätze mit Zeilenumbruch)<textarea name="inhalt" rows="7">{a.inhalt||''}</textarea></label>
<label>Bild-URL<input name="bild_url" value={a.bild_url||''} placeholder="leer = Platzhalterbild"/></label>
<div class="formbtns"><button class="btn" type="submit">Speichern</button><a href="/admin" class="btn ghost">Abbrechen</a></div>
</form>
</div>
</div>
<style>
.aform{padding-top:28px}.back{color:var(--slate);font-weight:600;font-size:.9rem}.aform h1{font-size:2rem;margin:.2em 0 .8em}
form label{display:block;margin-bottom:16px;font-weight:600;font-size:.88rem;color:var(--graphit)}
input,textarea{width:100%;margin-top:6px;padding:10px 12px;border:1.5px solid var(--line);border-radius:10px;font:inherit;background:var(--weiss);color:var(--ink)}
input:focus,textarea:focus{outline:none;border-color:var(--petrol)}
.row{display:grid;grid-template-columns:1fr 1fr;gap:14px}.formbtns{display:flex;gap:12px;margin-top:8px}
@media(max-width:640px){.row{grid-template-columns:1fr}}
</style>
</Base>
+57
View File
@@ -0,0 +1,57 @@
---
import Base from '../../layouts/Base.astro';
import { allProducts, allArticles, eur } from '../../lib/db.js';
const produkte = allProducts();
const artikel = allArticles();
const flash = Astro.url.searchParams.get('ok');
---
<Base title="Admin" admin={true}>
<div class="wrap" style="padding-top:28px;min-height:60vh">
<div class="ahead">
<div><span class="eyebrow">Backend</span><h1>Produktverwaltung</h1></div>
</div>
{flash && <div class="flash">✓ {flash}</div>}
<div class="abar"><h2>Produkte ({produkte.length})</h2><a class="btn" href="/admin/produkt/neu">+ Neues Produkt</a></div>
<div class="tablewrap"><table>
<thead><tr><th></th><th>Name</th><th>Kategorie</th><th>Preis</th><th>MwSt</th><th>Lager</th><th></th></tr></thead>
<tbody>{produkte.map(p=>(<tr>
<td><img src={p.bild_url} alt=""/></td>
<td><strong>{p.name}</strong><br/><span class="muted">{p.slug}</span></td>
<td>{p.kategorie}</td><td>{eur(p.preis)}</td><td>{p.mwst}%</td>
<td>{p.lagernd ? <span class="ok">auf Lager</span> : <span class="no">ausverkauft</span>}</td>
<td class="actions"><a href={`/admin/produkt/${p.id}`}>bearbeiten</a>
<form method="POST" action={`/admin/produkt/${p.id}`} onsubmit="return confirm('Produkt wirklich löschen?')"><input type="hidden" name="_action" value="delete"/><button class="del">löschen</button></form>
</td></tr>))}</tbody>
</table></div>
<div class="abar" style="margin-top:48px"><h2>Journal-Artikel ({artikel.length})</h2><a class="btn" href="/admin/artikel/neu">+ Neuer Artikel</a></div>
<div class="tablewrap"><table>
<thead><tr><th>Titel</th><th>Datum</th><th></th></tr></thead>
<tbody>{artikel.map(a=>(<tr>
<td><strong>{a.titel}</strong><br/><span class="muted">{a.slug}</span></td>
<td>{a.datum}</td>
<td class="actions"><a href={`/admin/artikel/${a.id}`}>bearbeiten</a>
<form method="POST" action={`/admin/artikel/${a.id}`} onsubmit="return confirm('Artikel wirklich löschen?')"><input type="hidden" name="_action" value="delete"/><button class="del">löschen</button></form>
</td></tr>))}</tbody>
</table></div>
<p style="margin-top:30px"><a href="/" class="btn ghost">← Zum Shop</a></p>
</div>
<style>
.ahead{margin-bottom:18px}.eyebrow{font-size:.74rem;letter-spacing:.16em;text-transform:uppercase;color:var(--slate);font-weight:700}
.ahead h1{font-size:2rem;margin:.15em 0 0}
.abar{display:flex;justify-content:space-between;align-items:center;margin:22px 0 12px}
.abar h2{font-size:1.3rem;margin:0}
.flash{background:oklch(0.92 0.06 150);color:oklch(0.35 0.08 150);padding:10px 16px;border-radius:10px;margin-bottom:16px;font-weight:600}
.tablewrap{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--weiss)}
table{width:100%;border-collapse:collapse;font-size:.92rem}
th,td{text-align:left;padding:12px 14px;border-bottom:1px solid var(--line);vertical-align:middle}
th{font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;color:var(--slate)}
td img{width:46px;height:46px;object-fit:cover;border-radius:8px}
.muted{color:var(--slate);font-size:.8rem}
.ok{color:var(--petrol-deep);font-weight:600}.no{color:oklch(0.55 0.13 25);font-weight:600}
.actions{display:flex;gap:12px;align-items:center;white-space:nowrap}
.actions a{color:var(--petrol);font-weight:600}
.actions form{margin:0}.del{background:none;border:none;color:oklch(0.55 0.13 25);cursor:pointer;text-decoration:underline;font:inherit}
</style>
</Base>
+69
View File
@@ -0,0 +1,69 @@
---
import Base from '../../../layouts/Base.astro';
import { productById, upsertProduct, deleteProduct } from '../../../lib/db.js';
const { id } = Astro.params;
const isNew = id === 'neu';
const slugify = (s)=>s.toLowerCase().replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss').replace(/[^a-z0-9]+/g,'-').replace(/(^-|-$)/g,'');
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData();
if (f.get('_action') === 'delete') { deleteProduct(Number(id)); return Astro.redirect('/admin?ok=Produkt+gelöscht'); }
const name = (f.get('name')||'').toString().trim();
const data = {
id: isNew ? null : Number(id),
name,
slug: (f.get('slug')||'').toString().trim() || slugify(name),
kategorie: (f.get('kategorie')||'').toString().trim(),
preis: parseFloat((f.get('preis')||'0').toString().replace(',','.'))||0,
mwst: parseInt(f.get('mwst')||'19')||19,
einheit: (f.get('einheit')||'').toString().trim(),
kurz: (f.get('kurz')||'').toString().trim(),
beschreibung: (f.get('beschreibung')||'').toString().trim(),
bild_url: (f.get('bild_url')||'').toString().trim() || `https://picsum.photos/seed/${slugify(name)||'produkt'}/900/1100`,
badge: (f.get('badge')||'').toString().trim(),
lagernd: f.get('lagernd') ? 1 : 0
};
upsertProduct(data);
return Astro.redirect('/admin?ok=Produkt+gespeichert');
}
const p = isNew ? {} : (productById(Number(id)) || {});
---
<Base title={isNew ? 'Neues Produkt' : 'Produkt bearbeiten'} admin={true}>
<div class="wrap aform">
<a href="/admin" class="back">← Übersicht</a>
<h1>{isNew ? 'Neues Produkt' : 'Produkt bearbeiten'}</h1>
<form method="POST">
<label>Name<input name="name" value={p.name||''} required/></label>
<div class="row">
<label>Slug (optional)<input name="slug" value={p.slug||''} placeholder="wird automatisch erzeugt"/></label>
<label>Kategorie<input name="kategorie" value={p.kategorie||''} list="kats"/>
<datalist id="kats"><option>Keramik</option><option>Textil</option><option>Licht</option><option>Möbel</option><option>Accessoires</option></datalist></label>
</div>
<div class="row">
<label>Preis (EUR, brutto)<input name="preis" value={p.preis??''} inputmode="decimal" required/></label>
<label>MwSt %<input name="mwst" type="number" value={p.mwst??19}/></label>
<label>Einheit<input name="einheit" value={p.einheit||''} placeholder="z. B. H 24 cm"/></label>
</div>
<label>Kurzbeschreibung<input name="kurz" value={p.kurz||''}/></label>
<label>Beschreibung<textarea name="beschreibung" rows="4">{p.beschreibung||''}</textarea></label>
<div class="row">
<label>Bild-URL<input name="bild_url" value={p.bild_url||''} placeholder="leer = Platzhalterbild"/></label>
<label>Badge<input name="badge" value={p.badge||''} placeholder="z. B. Neu, Handmade"/></label>
</div>
<label class="check"><input type="checkbox" name="lagernd" checked={isNew ? true : !!p.lagernd}/> auf Lager / lieferbar</label>
<div class="formbtns"><button class="btn" type="submit">Speichern</button><a href="/admin" class="btn ghost">Abbrechen</a></div>
</form>
</div>
<style>
.aform{max-width:720px;padding-top:28px}.back{color:var(--slate);font-weight:600;font-size:.9rem}
.aform h1{font-size:2rem;margin:.2em 0 .8em}
form label{display:block;margin-bottom:16px;font-weight:600;font-size:.88rem;color:var(--graphit)}
input,textarea,select{width:100%;margin-top:6px;padding:10px 12px;border:1.5px solid var(--line);border-radius:10px;font:inherit;background:var(--weiss);color:var(--ink)}
input:focus,textarea:focus{outline:none;border-color:var(--petrol)}
.row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}
.row:has(label:nth-child(2):last-child){grid-template-columns:1fr 1fr}
.check{display:flex;align-items:center;gap:8px}.check input{width:auto;margin:0}
.formbtns{display:flex;gap:12px;margin-top:8px}
@media(max-width:640px){.row{grid-template-columns:1fr}}
</style>
</Base>
+12
View File
@@ -0,0 +1,12 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="AGB"><article class="wrap legal">
<span class="demo-note">⚠ Demo-Inhalt · Platzhaltertexte ohne Rechtswirkung</span>
<h1>Allgemeine Geschäftsbedingungen</h1>
<h3>§ 1 Geltungsbereich</h3><p>Diese AGB gelten für alle Bestellungen über diesen (Demo-)Onlineshop.</p>
<h3>§ 2 Vertragspartner</h3><p>Der Kaufvertrag kommt zustande mit der NORDLICHT Concept Store (Musterbetrieb).</p>
<h3>§ 3 Preise &amp; Versand</h3><p>Alle Preise inkl. gesetzlicher MwSt. zzgl. Versand. In dieser Demo findet keine echte Bestellung statt.</p>
<h3>§ 4 Zahlung</h3><p>In der Live-Version stünden gängige Zahlarten bereit. In der Demo ist der Checkout deaktiviert.</p>
<p class="src">Platzhalter-AGB für Demonstrationszwecke.</p>
</article></Base>
+12
View File
@@ -0,0 +1,12 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="Datenschutz"><article class="wrap legal">
<span class="demo-note">⚠ Demo-Inhalt · Platzhaltertexte ohne Rechtswirkung</span>
<h1>Datenschutzerklärung</h1>
<h3>1. Verantwortlicher</h3><p>NORDLICHT Concept Store (Musterbetrieb), Lichtweg 7, 26506 Musterstadt.</p>
<h3>2. Hosting</h3><p>Diese Demo läuft auf Servern in Deutschland (Hetzner, Nürnberg). Es findet keine werbliche Datenverarbeitung statt.</p>
<h3>3. Warenkorb</h3><p>Der Warenkorb wird ausschließlich lokal im Browser (localStorage) gespeichert. Es werden keine Bestell- oder Zahlungsdaten übertragen.</p>
<h3>4. Deine Rechte</h3><p>Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit und Widerspruch gemäß DSGVO.</p>
<p class="src">Platzhalter-Datenschutzerklärung für Demonstrationszwecke.</p>
</article></Base>
+11
View File
@@ -0,0 +1,11 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="Impressum"><article class="wrap legal">
<span class="demo-note">⚠ Demo-Inhalt · Platzhaltertexte ohne Rechtswirkung</span>
<h1>Impressum</h1><p>Angaben gemäß § 5 DDG</p>
<p><strong>NORDLICHT Concept Store (Musterbetrieb)</strong><br/>Lichtweg 7<br/>26506 Musterstadt</p>
<p>Vertreten durch: Lina Muster<br/>Telefon: 0123 456789<br/>E-Mail: hallo@example.de</p>
<p>Umsatzsteuer-ID: DE000000000</p>
<p class="src">Technische Umsetzung: Heidrich Digital · Demoshop (Eigenbau Astro + SQLite)</p>
</article></Base>
+79
View File
@@ -0,0 +1,79 @@
---
import Base from '../layouts/Base.astro';
import { allProducts, allArticles, eur } from '../lib/db.js';
const produkte = allProducts();
const artikel = allArticles();
const kategorien = [...new Set(produkte.map(p => p.kategorie))];
---
<Base>
<section class="hero"><div class="wrap hero-in">
<div>
<span class="eyebrow">Design aus dem <span class="u">Norden</span></span>
<h1>Schöne Dinge,<br/>die bleiben.</h1>
<p class="lead">Keramik, Textil und Licht von kleinen Manufakturen. Sorgfältig ausgewählt, ehrlich gemacht — für ein ruhiges Zuhause.</p>
<div class="hero-cta"><a href="#shop" class="btn">Zum Shop</a><a href="#journal" class="btn ghost">Journal lesen</a></div>
</div>
<div class="hero-card"><img src="https://picsum.photos/seed/nordlicht-hero/760/860" alt="Interior" loading="eager"/></div>
</div></section>
<section id="shop" class="wrap">
<div class="sec-head"><h2>Sortiment</h2>
<div class="filters"><button class="chip active" data-cat="all">Alle</button>{kategorien.map(k=><button class="chip" data-cat={k}>{k}</button>)}</div>
</div>
<div class="grid">{produkte.map(p=>(
<article class="card" data-cat={p.kategorie}>
<a href={`/produkt/${p.slug}`} class="thumb"><img src={p.bild_url} alt={p.name} loading="lazy"/>{p.badge && <span class="badge">{p.badge}</span>}{!p.lagernd && <span class="badge out">Ausverkauft</span>}</a>
<div class="card-body"><span class="cat">{p.kategorie}</span><h3><a href={`/produkt/${p.slug}`}>{p.name}</a></h3><p class="kurz" set:html={p.kurz}></p>
<div class="card-foot"><div><strong class="price">{eur(p.preis)}</strong><span class="einheit">{p.einheit}</span></div>
{p.lagernd ? <button class="btn add" data-add={JSON.stringify({slug:p.slug,name:p.name,preis:p.preis,bild_url:p.bild_url,einheit:p.einheit,mwst:p.mwst})}>In den Korb</button> : <button class="btn" disabled style="opacity:.5;cursor:not-allowed">Vergriffen</button>}</div>
</div>
</article>))}</div>
</section>
<section id="journal" class="wrap" style="margin-top:72px">
<div class="sec-head"><h2>Journal</h2></div>
<div class="mag-grid">{artikel.map(a=>(
<a href={`/magazin/${a.slug}`} class="mag"><div class="mag-img"><img src={a.bild_url} alt={a.titel} loading="lazy"/></div>
<div class="mag-body"><time>{new Date(a.datum).toLocaleDateString('de-DE',{day:'2-digit',month:'long',year:'numeric'})}</time><h3>{a.titel}</h3><p>{a.teaser}</p><span class="more">Weiterlesen →</span></div></a>))}</div>
</section>
<style>
.hero{background:linear-gradient(180deg,var(--soft),var(--light));padding:54px 0 30px}
.hero-in{display:grid;grid-template-columns:1.1fr .9fr;gap:48px;align-items:center}
.eyebrow{font-size:.78rem;letter-spacing:.14em;text-transform:uppercase;color:var(--slate);font-weight:700}
.hero h1{font-size:clamp(2.2rem,5vw,3.6rem);margin:.3em 0 .2em}
.lead{font-size:1.12rem;color:var(--graphit);max-width:46ch}
.hero-cta{display:flex;gap:12px;margin-top:24px;flex-wrap:wrap}
.hero-card img{border-radius:20px;width:100%;aspect-ratio:7/8;object-fit:cover;box-shadow:0 30px 60px oklch(0.3 0.04 230/.18)}
.sec-head{display:flex;justify-content:space-between;align-items:flex-end;gap:16px;margin:56px 0 26px;flex-wrap:wrap}
.sec-head h2{font-size:clamp(1.7rem,3vw,2.3rem);margin:0}
.filters{display:flex;gap:8px;flex-wrap:wrap}
.chip{border:1.5px solid var(--line);background:var(--weiss);border-radius:999px;padding:.4em 1em;font:inherit;font-size:.86rem;cursor:pointer;color:var(--graphit);transition:.16s}
.chip:hover{border-color:var(--petrol)}.chip.active{background:var(--petrol);color:#fff;border-color:var(--petrol)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:24px}
.card{background:var(--weiss);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden;display:flex;flex-direction:column;transition:transform .2s,box-shadow .2s}
.card:hover{transform:translateY(-4px);box-shadow:0 18px 40px oklch(0.3 0.03 230/.12)}
.thumb{position:relative;display:block;aspect-ratio:4/5;overflow:hidden}
.thumb img{width:100%;height:100%;object-fit:cover;transition:transform .35s}.card:hover .thumb img{transform:scale(1.05)}
.badge{position:absolute;top:12px;left:12px;background:var(--petrol);color:#fff;font-size:.72rem;font-weight:700;padding:4px 10px;border-radius:999px}
.badge.out{background:var(--graphit);left:auto;right:12px}
.card-body{padding:16px 18px 18px;display:flex;flex-direction:column;flex:1}
.cat{font-size:.7rem;text-transform:uppercase;letter-spacing:.1em;color:var(--slate);font-weight:700}
.card-body h3{font-size:1.18rem;margin:.3em 0 .25em}.kurz{color:var(--graphit);font-size:.9rem;flex:1}
.card-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px}
.price{font-size:1.18rem}.einheit{display:block;font-size:.72rem;color:var(--slate)}.add{padding:.55em 1em;font-size:.88rem}
.mag-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:24px}
.mag{background:var(--weiss);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden;display:flex;flex-direction:column;transition:.2s}
.mag:hover{transform:translateY(-4px);box-shadow:0 18px 40px oklch(0.3 0.03 230/.12)}
.mag-img{aspect-ratio:16/9;overflow:hidden}.mag-img img{width:100%;height:100%;object-fit:cover}
.mag-body{padding:18px 20px}.mag-body time{font-size:.76rem;color:var(--slate);text-transform:uppercase;letter-spacing:.08em}
.mag-body h3{font-size:1.25rem;margin:.35em 0 .3em}.mag-body p{color:var(--graphit);font-size:.92rem}.more{color:var(--petrol);font-weight:600;font-size:.9rem}
@media(max-width:820px){.hero-in{grid-template-columns:1fr}.hero-card{order:-1}}
</style>
<script is:inline>
document.querySelectorAll('.chip').forEach(c=>c.addEventListener('click',()=>{
document.querySelectorAll('.chip').forEach(x=>x.classList.remove('active'));c.classList.add('active');
const cat=c.dataset.cat;document.querySelectorAll('.card').forEach(card=>{card.style.display=(cat==='all'||card.dataset.cat===cat)?'':'none'});
}));
</script>
</Base>
+16
View File
@@ -0,0 +1,16 @@
---
import Base from '../../layouts/Base.astro';
import { articleBySlug } from '../../lib/db.js';
const { slug } = Astro.params;
const a = articleBySlug(slug);
if (!a) return Astro.redirect('/');
---
<Base title={a.titel}>
<article class="wrap legal" style="max-width:760px">
<a href="/#journal" class="back" style="color:var(--slate);font-weight:600;font-size:.9rem">← Zurück zum Journal</a>
<time style="display:block;margin-top:18px;color:var(--slate);text-transform:uppercase;letter-spacing:.08em;font-size:.8rem">{new Date(a.datum).toLocaleDateString('de-DE',{day:'2-digit',month:'long',year:'numeric'})}</time>
<h1>{a.titel}</h1><p style="font-size:1.2rem;color:var(--graphit)">{a.teaser}</p>
<img src={a.bild_url} alt={a.titel} style="width:100%;border-radius:16px;margin:24px 0;aspect-ratio:16/9;object-fit:cover"/>
<div>{a.inhalt.split('\n').map(p => p.trim() && <p style="font-size:1.05rem;margin:0 0 1.1em">{p}</p>)}</div>
</article>
</Base>
+39
View File
@@ -0,0 +1,39 @@
---
import Base from '../../layouts/Base.astro';
import { productBySlug, allProducts, eur } from '../../lib/db.js';
const { slug } = Astro.params;
const p = productBySlug(slug);
if (!p) return Astro.redirect('/');
const mehr = allProducts().filter(x => x.kategorie===p.kategorie && x.slug!==p.slug).slice(0,3);
---
<Base title={p.name}>
<div class="wrap" style="padding-top:26px">
<a href="/#shop" class="back">← Zurück zum Shop</a>
<div class="detail">
<div class="d-img"><img src={p.bild_url} alt={p.name}/>{p.badge && <span class="badge">{p.badge}</span>}</div>
<div class="d-info"><span class="cat">{p.kategorie}</span><h1>{p.name}</h1><p class="kurz" set:html={p.kurz}></p>
<div class="priceblock"><strong>{eur(p.preis)}</strong><span>{p.einheit} · inkl. {p.mwst}% MwSt.</span></div>
<p class="stock">{p.lagernd ? '✓ Auf Lager — sofort lieferbar' : '✗ Derzeit ausverkauft'}</p>
{p.lagernd ? <button class="btn big" data-add={JSON.stringify({slug:p.slug,name:p.name,preis:p.preis,bild_url:p.bild_url,einheit:p.einheit,mwst:p.mwst})}>In den Warenkorb</button> : <button class="btn big" disabled style="opacity:.5">Vergriffen</button>}
<div class="desc"><h3>Produktbeschreibung</h3><p>{p.beschreibung}</p></div>
</div>
</div>
{mehr.length>0 && (<section style="margin-top:64px"><h2>Passt dazu</h2>
<div class="rel">{mehr.map(m=>(<a href={`/produkt/${m.slug}`} class="relcard"><img src={m.bild_url} alt={m.name}/><div><span class="cat">{m.kategorie}</span><strong>{m.name}</strong><span class="p">{eur(m.preis)}</span></div></a>))}</div></section>)}
</div>
<style>
.back{color:var(--slate);font-weight:600;font-size:.9rem}
.detail{display:grid;grid-template-columns:1fr 1fr;gap:46px;margin-top:18px}
.d-img{position:relative;border-radius:18px;overflow:hidden}.d-img img{width:100%;aspect-ratio:4/5;object-fit:cover}
.d-img .badge{position:absolute;top:14px;left:14px;background:var(--petrol);color:#fff;font-size:.74rem;font-weight:700;padding:4px 12px;border-radius:999px}
.cat{font-size:.72rem;text-transform:uppercase;letter-spacing:.1em;color:var(--slate);font-weight:700}
.d-info h1{font-size:clamp(1.8rem,3.5vw,2.6rem);margin:.2em 0 .3em}.kurz{font-size:1.08rem;color:var(--graphit)}
.priceblock{display:flex;align-items:baseline;gap:12px;margin:18px 0 6px}.priceblock strong{font-size:1.9rem;font-family:'Fraunces Variable',serif}.priceblock span{color:var(--slate);font-size:.9rem}
.stock{color:var(--petrol-deep);font-weight:600;font-size:.92rem;margin:.3em 0 1.1em}.btn.big{font-size:1.02rem;padding:.85em 1.8em}
.desc{margin-top:34px;border-top:1px solid var(--line);padding-top:24px}.desc p{color:var(--graphit);white-space:pre-line}
.rel{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:20px;margin-top:18px}
.relcard{background:var(--weiss);border:1px solid var(--line);border-radius:12px;overflow:hidden}.relcard img{width:100%;aspect-ratio:4/3;object-fit:cover}
.relcard div{padding:12px 14px;display:flex;flex-direction:column;gap:2px}.relcard .p{color:var(--petrol);font-weight:700}
@media(max-width:780px){.detail{grid-template-columns:1fr;gap:24px}}
</style>
</Base>
+20
View File
@@ -0,0 +1,20 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="Warenkorb">
<div class="wrap" style="padding-top:30px;min-height:50vh">
<h1>Dein Warenkorb</h1>
<div id="page-cart" style="margin-top:20px;max-width:760px"><p class="empty">Wird geladen …</p></div>
</div>
<script is:inline>
const eur=n=>new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'}).format(n);
function paint(){
const c=window.HK?window.HK.get():[];const el=document.getElementById('page-cart');
if(!c.length){el.innerHTML='<p class="empty">Dein Warenkorb ist leer. <a href="/#shop" style="color:var(--petrol)">Weiter einkaufen →</a></p>';return}
const t=window.HK.totals();
el.innerHTML=c.map(i=>`<div style="display:flex;gap:16px;align-items:center;padding:16px 0;border-bottom:1px solid var(--line)"><img src="${i.bild_url}" style="width:80px;height:80px;border-radius:10px;object-fit:cover"><div style="flex:1"><div style="font-weight:600">${i.name}</div><div style="color:var(--slate);font-size:.85rem">${eur(i.preis)} · ${i.einheit||''}</div><div style="display:flex;gap:8px;align-items:center;margin-top:6px"><button data-dec="${i.slug}" style="width:28px;height:28px;border:1px solid var(--line);background:var(--light);border-radius:7px;cursor:pointer"></button><span>${i.qty}</span><button data-inc="${i.slug}" style="width:28px;height:28px;border:1px solid var(--line);background:var(--light);border-radius:7px;cursor:pointer">+</button><button data-rm="${i.slug}" style="margin-left:10px;background:none;border:none;color:var(--slate);text-decoration:underline;cursor:pointer">entfernen</button></div></div><strong>${eur(i.preis*i.qty)}</strong></div>`).join('')
+`<div style="margin-top:24px;text-align:right"><div style="color:var(--graphit);font-size:.9rem">inkl. ${eur(t.tax)} MwSt.</div><div style="font-size:1.5rem;font-family:'Fraunces Variable',serif;font-weight:600">Gesamt: ${eur(t.total)}</div><button class="btn" style="margin-top:14px" onclick="alert('Demoshop — keine echte Bestellung.')">Zur Kasse (Demo)</button></div>`;
}
document.addEventListener('cart:changed',paint);setTimeout(paint,60);
</script>
</Base>
+10
View File
@@ -0,0 +1,10 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="Widerrufsrecht"><article class="wrap legal">
<span class="demo-note">⚠ Demo-Inhalt · Platzhaltertexte ohne Rechtswirkung</span>
<h1>Widerrufsbelehrung</h1><p>Verbraucher haben ein vierzehntägiges Widerrufsrecht.</p>
<h3>Widerrufsrecht</h3><p>Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. Die Frist beginnt mit dem Tag, an dem Sie die Waren in Besitz genommen haben.</p>
<h3>Folgen des Widerrufs</h3><p>Im Falle eines wirksamen Widerrufs werden bereits erhaltene Zahlungen unverzüglich zurückerstattet.</p>
<p class="src">Platzhalter-Widerrufsbelehrung für Demonstrationszwecke.</p>
</article></Base>