Demoshop 1 — NORDLICHT Eigenbau-Shop (Astro SSR + SQLite + Admin-Backend, Katalog, Warenkorb, Journal, Rechtstexte)
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.astro
|
||||||
|
.git
|
||||||
|
data
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.astro
|
||||||
|
data
|
||||||
+12
@@ -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"]
|
||||||
@@ -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 }
|
||||||
|
});
|
||||||
Generated
+6128
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
@@ -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>
|
||||||
@@ -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 & 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));
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 & 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user