v2.3: Feature-Module live — Suche, Merkliste, Kundenkonten+Adressbuch, Bewertungen, Abandoned-Cart

- feature_search: Storefront-Header-Suche + /suche (SSR, SQLite LIKE, case-insensitiv; Name/Kurz/Desc/Material/Kategorie), Treffer als Karten, Leer-Zustand
- feature_wishlist: Herz-Button auf Karten/PDP (localStorage, public/wishlist.js) + /merkliste
- feature_accounts: getrennte Kunden-Session (Cookie hdc_customer, scrypt), /konto/registrieren|anmelden|abmelden, /konto (Bestellhistorie+Adressbuch), Tabelle customer_addresses, Checkout-Vorbefuellung + orders.customer_id-Zuordnung; Gast-Checkout bleibt
- feature_reviews: Tabelle reviews (1-5, Moderation), /api/review (approved=0), PDP-Anzeige Durchschnitt+Reviews + aggregateRating-JSON-LD, Admin /bewertungen (Freigeben/Verbergen/Loeschen) + Nav-Zaehler
- feature_abandoned_cart: Tabelle abandoned_carts, /api/cart-capture beim Checkout-Start, /api/cron/abandoned (CRON_TOKEN) sendet Erinnerungsmail (Mailer/Log) + reminded=1, recovered=1 bei Bestellung; Status in Einstellungen
- Gating: Flag aus => Storefront-Elemente weg, Routen 302/404, Admin-Nav-Punkt entfaellt; KEIN 'in Vorbereitung' mehr
- API/MCP: reviews CRUD + abandoned_carts (read) in admin-api + ai-admin.txt + MCP-Tools; Manifest v2.3
- README + .env.example (CRON_TOKEN, ABANDONED_AFTER_MINUTES); 16 neue Unit-Tests (Suche/Review-Avg/Kunden/Abandoned)
This commit is contained in:
2026-06-18 07:27:34 +00:00
parent 3fe80e76dd
commit 30c41c355e
32 changed files with 1181 additions and 42 deletions
+7
View File
@@ -49,3 +49,10 @@ SMTP_PORT=587
SMTP_USER= SMTP_USER=
SMTP_PASS= SMTP_PASS=
SMTP_SECURE=false SMTP_SECURE=false
# --- Feature-Module (v2.3) ---
# Warenkorb-Erinnerung: Token, das den Cron-Endpoint /api/cron/abandoned schützt.
# Leer => Endpoint bleibt gesperrt (401). Coolify-Scheduled-Task ruft den Endpoint mit diesem Token.
CRON_TOKEN=
# Karten, die älter als X Minuten sind und weder bezahlt noch erinnert wurden, werden erinnert.
ABANDONED_AFTER_MINUTES=30
+8
View File
@@ -17,6 +17,12 @@ Die mitgelieferte Demo-Instanz heißt **„Brittas Nähkiste"** (Kurzwaren/Nähb
- **Versandzonen** (Tabelle `shipping_zones`, Admin „Versand"): länderbasierte Preise mit Gratis-ab-Schwelle; Helper `shippingFor(country, subtotal)`; Checkout berechnet Versand serverseitig neu. - **Versandzonen** (Tabelle `shipping_zones`, Admin „Versand"): länderbasierte Preise mit Gratis-ab-Schwelle; Helper `shippingFor(country, subtotal)`; Checkout berechnet Versand serverseitig neu.
- **Bestell-/Versandmails** (`src/lib/mailer.js`): Provider **Listmonk** (Transactional-API) / **SMTP** (nodemailer) / **Log-Fallback** (Tabelle `email_log`, Admin „E-Mail-Log"). Gebrandete Bestellbestätigung bei bezahlter Bestellung. - **Bestell-/Versandmails** (`src/lib/mailer.js`): Provider **Listmonk** (Transactional-API) / **SMTP** (nodemailer) / **Log-Fallback** (Tabelle `email_log`, Admin „E-Mail-Log"). Gebrandete Bestellbestätigung bei bezahlter Bestellung.
- **Feature-Flags**: Module pro Shop abschaltbar (`feature_newsletter`, `feature_accounts`, `feature_reviews`, `feature_wishlist`, `feature_abandoned_cart`, `feature_search`) über Admin → Einstellungen → Module; Helper `feature(key)`. - **Feature-Flags**: Module pro Shop abschaltbar (`feature_newsletter`, `feature_accounts`, `feature_reviews`, `feature_wishlist`, `feature_abandoned_cart`, `feature_search`) über Admin → Einstellungen → Module; Helper `feature(key)`.
- **Feature-Module (v2.3):** über die jeweiligen Flags ge-gatet — ist ein Flag aus, verschwinden Storefront-Elemente, Routen liefern 302/404 und der Admin-Nav-Punkt entfällt.
- **Volltextsuche** (`feature_search`): Suchfeld im Storefront-Header → Ergebnisseite `/suche?q=` (SSR, SQLite-`LIKE`, case-insensitiv über Name/Kurzname/Beschreibung/Material/Kategorie), Treffer als Produktkarten, „keine Treffer"-Zustand.
- **Merkliste** (`feature_wishlist`): Herz-Button auf Produktkarten & Detailseite, Speicherung clientseitig (localStorage, `public/wishlist.js`), Seite `/merkliste`.
- **Kundenkonten + Adressbuch** (`feature_accounts`): eigene Kunden-Session (Cookie `hdc_customer`, getrennt vom Admin; scrypt-Hash). `/konto/registrieren`, `/konto/anmelden`, `/konto` (Bestellhistorie + Adressbuch), `/konto/abmelden`. Tabelle `customer_addresses`; Checkout füllt die Adresse vor und ordnet die Bestellung dem Konto zu (`orders.customer_id`). Gast-Checkout bleibt möglich.
- **Bewertungen** (`feature_reviews`): Tabelle `reviews` (Sterne 15, Moderation `approved`). Formular auf der Produktseite (`/api/review`, speichert `approved=0`), Anzeige von Durchschnitt + freigegebenen Reviews, optionale `aggregateRating` im Produkt-JSON-LD. Admin-Bereich **Bewertungen** (Owner/Redaktion): Freigeben/Verbergen/Löschen, Zähler offener Reviews in der Nav.
- **Warenkorb-Erinnerung** (`feature_abandoned_cart`): beim Checkout-Start wird der Warenkorb serverseitig in `abandoned_carts` gesichert (`/api/cart-capture`). Versand-Trigger: **`POST /api/cron/abandoned`** (Header `Authorization: Bearer <CRON_TOKEN>` oder `?token=`), schickt für fällige, nicht erinnerte Karten eine gebrandete Erinnerungsmail (Mailer/Log-Fallback) und setzt `reminded=1`. Erfolgreiche Bestellung der Adresse setzt `recovered=1`. Status/Zähler unter Einstellungen. Als **Coolify-Scheduled-Task** z. B. alle 30 Min `curl -fsS -X POST -H "Authorization: Bearer $CRON_TOKEN" https://shop.example.com/api/cron/abandoned` aufrufen.
- **Editierbare, gebrandete 404** (v2.1): `src/pages/404.astro` rendert die System-Seite mit Slug `404` über den Block-Builder. Wird per `ensureSystemPages()` bei jedem Boot idempotent angelegt und ist im Admin unter **Inhalte** editierbar. - **Editierbare, gebrandete 404** (v2.1): `src/pages/404.astro` rendert die System-Seite mit Slug `404` über den Block-Builder. Wird per `ensureSystemPages()` bei jedem Boot idempotent angelegt und ist im Admin unter **Inhalte** editierbar.
- **Engine**: synchron via `better-sqlite3` (WAL), automatisches Seeding beim ersten Start. - **Engine**: synchron via `better-sqlite3` (WAL), automatisches Seeding beim ersten Start.
- **First-Party-Analytics**: eigene `events`-Tabelle, kein externer Dienst (Session = täglich rollender Hash). - **First-Party-Analytics**: eigene `events`-Tabelle, kein externer Dienst (Session = täglich rollender Hash).
@@ -49,6 +55,8 @@ Die mitgelieferte Demo-Instanz heißt **„Brittas Nähkiste"** (Kurzwaren/Nähb
| `MAIL_FROM` | Absenderadresse ausgehender Mails | | | `MAIL_FROM` | Absenderadresse ausgehender Mails | |
| `LISTMONK_URL` / `LISTMONK_USER` / `LISTMONK_PASS` / `LISTMONK_TX_TEMPLATE_ID` | Listmonk Transactional-API | | | `LISTMONK_URL` / `LISTMONK_USER` / `LISTMONK_PASS` / `LISTMONK_TX_TEMPLATE_ID` | Listmonk Transactional-API | |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` / `SMTP_SECURE` | SMTP-Versand (nodemailer) | | | `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` / `SMTP_SECURE` | SMTP-Versand (nodemailer) | |
| `CRON_TOKEN` | Bearer-Token für `/api/cron/abandoned` (Warenkorb-Erinnerung). Leer ⇒ Endpoint gesperrt | |
| `ABANDONED_AFTER_MINUTES` | Alter (Minuten), ab dem eine offene Warenkorb-Karte erinnert wird | `30` |
Siehe `.env.example`. Siehe `.env.example`.
+9 -1
View File
@@ -41,10 +41,14 @@ const TOOLS = [
{ name: 'delete_shipping', description: 'Versandzone löschen (per ID).', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } }, { name: 'delete_shipping', description: 'Versandzone löschen (per ID).', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
{ name: 'get_settings', description: 'Shop-Einstellungen (Key/Value) holen.', inputSchema: { type: 'object', properties: {} } }, { name: 'get_settings', description: 'Shop-Einstellungen (Key/Value) holen.', inputSchema: { type: 'object', properties: {} } },
{ name: 'update_settings', description: 'Shop-Einstellungen aktualisieren (Key/Value-Map, z.B. shop_name, brand_accent).', inputSchema: { type: 'object', properties: { settings: { type: 'object' } }, required: ['settings'] } }, { name: 'update_settings', description: 'Shop-Einstellungen aktualisieren (Key/Value-Map, z.B. shop_name, brand_accent).', inputSchema: { type: 'object', properties: { settings: { type: 'object' } }, required: ['settings'] } },
{ name: 'list_reviews', description: 'Produktbewertungen auflisten (inkl. approved-Status).', inputSchema: { type: 'object', properties: {} } },
{ name: 'moderate_review', description: 'Bewertung moderieren: { id, approved: true|false }. Ohne id: neue Bewertung { product_slug, name, rating(1-5), text }.', inputSchema: { type: 'object', properties: { review: { type: 'object' } }, required: ['review'] } },
{ name: 'delete_review', description: 'Bewertung löschen (per ID).', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
{ name: 'list_abandoned_carts', description: 'Abgebrochene Warenkörbe auflisten (nur lesen).', inputSchema: { type: 'object', properties: {} } },
{ name: 'get_manifest', description: 'API-Manifest (alle Ressourcen, Felder, Block-Typen).', inputSchema: { type: 'object', properties: {} } }, { name: 'get_manifest', description: 'API-Manifest (alle Ressourcen, Felder, Block-Typen).', inputSchema: { type: 'object', properties: {} } },
]; ];
const server = new Server({ name: 'hd-commerce', version: '2.2.0' }, { capabilities: { tools: {} } }); const server = new Server({ name: 'hd-commerce', version: '2.3.0' }, { capabilities: { tools: {} } });
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
server.setRequestHandler(CallToolRequestSchema, async (req) => { server.setRequestHandler(CallToolRequestSchema, async (req) => {
@@ -71,6 +75,10 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
case 'delete_shipping': out = await api('DELETE', '/api/admin/shipping_zones/' + encodeURIComponent(a.id)); break; case 'delete_shipping': out = await api('DELETE', '/api/admin/shipping_zones/' + encodeURIComponent(a.id)); break;
case 'get_settings': out = await api('GET', '/api/admin/settings'); break; case 'get_settings': out = await api('GET', '/api/admin/settings'); break;
case 'update_settings': out = await api('POST', '/api/admin/settings', a.settings); break; case 'update_settings': out = await api('POST', '/api/admin/settings', a.settings); break;
case 'list_reviews': out = await api('GET', '/api/admin/reviews'); break;
case 'moderate_review': out = await api('POST', '/api/admin/reviews', a.review); break;
case 'delete_review': out = await api('DELETE', '/api/admin/reviews/' + encodeURIComponent(a.id)); break;
case 'list_abandoned_carts': out = await api('GET', '/api/admin/abandoned_carts'); break;
case 'get_manifest': out = await api('GET', '/api/admin'); break; case 'get_manifest': out = await api('GET', '/api/admin'); break;
default: throw new Error('Unbekanntes Tool: ' + name); default: throw new Error('Unbekanntes Tool: ' + name);
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "hd-commerce", "name": "hd-commerce",
"type": "module", "type": "module",
"version": "2.2.0", "version": "2.3.0",
"private": true, "private": true,
"description": "hd-commerce — neutrales SQLite-Commerce-Backend (Admin + API + Demo-Storefront)", "description": "hd-commerce — neutrales SQLite-Commerce-Backend (Admin + API + Demo-Storefront)",
"scripts": { "scripts": {
+75
View File
@@ -0,0 +1,75 @@
/* hd-commerce — Merkliste (Wunschliste) clientseitig via localStorage. */
(function () {
var KEY = 'hdc_wishlist';
function read() { try { return JSON.parse(localStorage.getItem(KEY) || '[]'); } catch (e) { return []; } }
function write(c) { localStorage.setItem(KEY, JSON.stringify(c)); updateBadge(); paintButtons(); }
function has(slug) { return read().some(function (i) { return i.slug === slug; }); }
function toggle(item) {
var c = read();
var idx = c.findIndex(function (i) { return i.slug === item.slug; });
if (idx > -1) { c.splice(idx, 1); } else { c.push(item); }
write(c);
return idx === -1; // true wenn jetzt gemerkt
}
function remove(slug) { write(read().filter(function (i) { return i.slug !== slug; })); }
function updateBadge() {
var b = document.getElementById('wishBadge');
if (!b) return;
var n = read().length;
b.textContent = n;
b.classList.toggle('show', n > 0);
}
function paintButtons() {
document.querySelectorAll('[data-wish]').forEach(function (btn) {
var p; try { p = JSON.parse(btn.getAttribute('data-wish') || '{}'); } catch (e) { p = {}; }
btn.classList.toggle('active', has(p.slug));
btn.setAttribute('aria-pressed', has(p.slug) ? 'true' : 'false');
});
}
window.HDCWish = { read: read, has: has, toggle: toggle, remove: remove, count: function () { return read().length; } };
document.addEventListener('DOMContentLoaded', function () {
updateBadge();
paintButtons();
document.querySelectorAll('[data-wish]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.preventDefault(); e.stopPropagation();
var p; try { p = JSON.parse(btn.getAttribute('data-wish') || '{}'); } catch (err) { p = {}; }
if (!p.slug) return;
var added = toggle(p);
if (window.HDC && window.HDC.track) { /* optional analytics */ }
if (typeof window.hdcWishToast === 'function') window.hdcWishToast(added ? 'Gemerkt' : 'Entfernt');
});
});
// Merkliste-Seite rendern, falls vorhanden
var root = document.getElementById('wishlistRoot');
if (root) renderList(root);
});
function fmt(c, cur) { try { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: cur || 'EUR' }).format((c||0)/100); } catch(e){ return ((c||0)/100).toFixed(2)+' '+(cur||'EUR'); } }
function renderList(root) {
var cur = root.getAttribute('data-currency') || 'EUR';
function draw() {
var items = read();
if (!items.length) {
root.innerHTML = '<div class="empty-state"><h2>Deine Merkliste ist leer</h2><p>Tippe auf das Herz an einem Produkt, um es hier zu sammeln.</p><a class="btn btn-primary btn-lg" href="/shop" style="margin-top:16px">Zum Shop</a></div>';
return;
}
var cards = items.map(function (p) {
return '<div class="prod-card-wrap">' +
'<a class="prod-card" href="/produkt/' + p.slug + '">' +
'<div class="prod-media">' + (p.image ? '<img src="' + p.image + '" alt="" loading="lazy">' : '') + '</div>' +
'<div class="prod-info"><span class="prod-cat">' + (p.category || '') + '</span>' +
'<span class="prod-name">' + (p.name || '') + '</span>' +
'<span class="prod-price">' + fmt(p.priceCents, cur) + '</span>' +
'<span class="prod-tax">inkl. MwSt.</span></div></a>' +
'<button class="wish-btn active" data-rm="' + p.slug + '" aria-label="Entfernen" title="Entfernen">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8Z"/></svg></button></div>';
}).join('');
root.innerHTML = '<div class="prod-grid">' + cards + '</div>';
root.querySelectorAll('[data-rm]').forEach(function (b) {
b.addEventListener('click', function (e) { e.preventDefault(); remove(b.getAttribute('data-rm')); draw(); });
});
}
draw();
}
})();
+5 -1
View File
@@ -2,7 +2,7 @@
import '@fontsource-variable/public-sans'; import '@fontsource-variable/public-sans';
import '@fontsource-variable/fraunces'; import '@fontsource-variable/fraunces';
import '../styles/admin.css'; import '../styles/admin.css';
import { getSettings } from '../lib/store.js'; import { getSettings, feature, countPendingReviews } from '../lib/store.js';
import { currentUser, adminBase, allowedSections } from '../lib/auth.js'; import { currentUser, adminBase, allowedSections } from '../lib/auth.js';
export interface Props { title: string; active?: string; crumbs?: { label: string; href?: string }[]; } export interface Props { title: string; active?: string; crumbs?: { label: string; href?: string }[]; }
@@ -32,6 +32,9 @@ const allNav = [
{ key:'inhalte', label:'Inhalte', href: base + '/inhalte', icon:'M4 4h16v4H4V4Zm0 6h10v10H4V10Zm12 0h4v10h-4V10Z' }, { key:'inhalte', label:'Inhalte', href: base + '/inhalte', icon:'M4 4h16v4H4V4Zm0 6h10v10H4V10Zm12 0h4v10h-4V10Z' },
{ key:'versandzonen', label:'Versand', href: base + '/versand', icon:'M3 7h11v8H3V7Zm11 3h4l3 3v2h-7v-5ZM7 19a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm10 0a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z' }, { key:'versandzonen', label:'Versand', href: base + '/versand', icon:'M3 7h11v8H3V7Zm11 3h4l3 3v2h-7v-5ZM7 19a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm10 0a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z' },
]; ];
const reviewsOn = feature('feature_reviews');
const pendingReviews = reviewsOn ? countPendingReviews() : 0;
if (reviewsOn) allNav.push({ key:'bewertungen', label:'Bewertungen', href: base + '/bewertungen', icon:'M12 17.3 6.2 21l1.6-6.6L3 9.8l6.7-.6L12 3l2.3 6.2 6.7.6-4.8 4.6L17.8 21 12 17.3Z', badge: pendingReviews });
const ownerNav = [ const ownerNav = [
{ key:'nutzer', label:'Nutzer & Zugänge', href: base + '/nutzer', icon:'M16 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0 2c-3 0-6 1.5-6 4v1h7M16 13c-3.3 0-6 1.7-6 4.2V19h12v-1.8c0-2.5-2.7-4.2-6-4.2Z' }, { key:'nutzer', label:'Nutzer & Zugänge', href: base + '/nutzer', icon:'M16 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0 2c-3 0-6 1.5-6 4v1h7M16 13c-3.3 0-6 1.7-6 4.2V19h12v-1.8c0-2.5-2.7-4.2-6-4.2Z' },
{ key:'audit', label:'Aktivität (Audit)', href: base + '/audit', icon:'M12 8v4l3 2M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z' }, { key:'audit', label:'Aktivität (Audit)', href: base + '/audit', icon:'M12 8v4l3 2M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z' },
@@ -72,6 +75,7 @@ const paletteJson = JSON.stringify(paletteItems);
<a href={n.href} class={active === n.key ? 'active' : ''}> <a href={n.href} class={active === n.key ? 'active' : ''}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={n.icon}/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={n.icon}/></svg>
{n.label} {n.label}
{n.badge > 0 && <span class="s-nav-badge">{n.badge}</span>}
</a> </a>
))} ))}
{ownerItems.length > 0 && <div class="s-nav-sec">Verwaltung</div>} {ownerItems.length > 0 && <div class="s-nav-sec">Verwaltung</div>}
+21 -3
View File
@@ -26,6 +26,9 @@ let popups = popupsForPath(path);
// Feature-Flag: Newsletter-Popups nur zeigen, wenn Newsletter aktiv ist. // Feature-Flag: Newsletter-Popups nur zeigen, wenn Newsletter aktiv ist.
const newsletterOn = feature('feature_newsletter'); const newsletterOn = feature('feature_newsletter');
if (!newsletterOn) popups = popups.filter((pp) => pp.type !== 'newsletter'); if (!newsletterOn) popups = popups.filter((pp) => pp.type !== 'newsletter');
const searchOn = feature('feature_search');
const accountsOn = feature('feature_accounts');
const wishlistOn = feature('feature_wishlist');
--- ---
<!doctype html> <!doctype html>
<html lang="de"> <html lang="de">
@@ -51,9 +54,23 @@ if (!newsletterOn) popups = popups.filter((pp) => pp.type !== 'newsletter');
<a href="/seite/ueber-uns">Über uns</a> <a href="/seite/ueber-uns">Über uns</a>
</nav> </nav>
<div class="head-actions"> <div class="head-actions">
<a class="icon-btn" href="/shop" aria-label="Suche"> {searchOn && (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg> <form class="head-search" method="GET" action="/suche" role="search">
</a> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input type="search" name="q" placeholder="Suchen …" aria-label="Produkte durchsuchen" autocomplete="off" />
</form>
)}
{wishlistOn && (
<a class="icon-btn" href="/merkliste" aria-label="Merkliste">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8Z"/></svg>
<span class="wish-badge" id="wishBadge">0</span>
</a>
)}
{accountsOn && (
<a class="icon-btn" href="/konto" aria-label="Mein Konto">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1"/></svg>
</a>
)}
<a class="icon-btn" href="/warenkorb" aria-label="Warenkorb"> <a class="icon-btn" href="/warenkorb" aria-label="Warenkorb">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>
<span class="cart-badge" id="cartBadge">0</span> <span class="cart-badge" id="cartBadge">0</span>
@@ -100,5 +117,6 @@ if (!newsletterOn) popups = popups.filter((pp) => pp.type !== 'newsletter');
<script src="/shop.js" is:inline></script> <script src="/shop.js" is:inline></script>
<script src="/popups.js" is:inline></script> <script src="/popups.js" is:inline></script>
{wishlistOn && <script src="/wishlist.js" is:inline></script>}
</body> </body>
</html> </html>
+21 -2
View File
@@ -28,6 +28,8 @@ export const RESOURCES = {
settings: { rw: true, fields: ['key/value-Map (shop_name, brand_accent, currency, free_shipping_cents, …)'] }, settings: { rw: true, fields: ['key/value-Map (shop_name, brand_accent, currency, free_shipping_cents, …)'] },
orders: { rw: false, fields: ['number', 'email', 'customer_name', 'status', 'total_cents', 'tax_cents', 'shipping_cents', 'country', 'items[]', 'address', 'created_at'] }, orders: { rw: false, fields: ['number', 'email', 'customer_name', 'status', 'total_cents', 'tax_cents', 'shipping_cents', 'country', 'items[]', 'address', 'created_at'] },
customers: { rw: false, fields: ['name', 'email', 'city', 'orders_count', 'total_spent_cents', 'created_at'] }, customers: { rw: false, fields: ['name', 'email', 'city', 'orders_count', 'total_spent_cents', 'created_at'] },
reviews: { rw: true, fields: ['product_slug', 'name', 'rating(1-5)', 'text', 'approved(0|1)', 'created_at'] },
abandoned_carts: { rw: false, fields: ['email', 'items[]', 'total_cents', 'recovered(0|1)', 'reminded(0|1)', 'created_at'] },
}; };
export function listResource(name) { export function listResource(name) {
@@ -41,6 +43,8 @@ export function listResource(name) {
case 'orders': return store.listOrders(); case 'orders': return store.listOrders();
case 'customers': return store.listCustomers(); case 'customers': return store.listCustomers();
case 'settings': return store.getSettings(); case 'settings': return store.getSettings();
case 'reviews': return store.listReviews();
case 'abandoned_carts': return store.listAbandonedCarts();
default: return null; default: return null;
} }
} }
@@ -54,6 +58,7 @@ export function getResource(name, id) {
case 'shipping_zones': return store.getShippingZoneById(id); case 'shipping_zones': return store.getShippingZoneById(id);
case 'orders': return store.getOrderById(id); case 'orders': return store.getOrderById(id);
case 'customers': return store.getCustomerById(id); case 'customers': return store.getCustomerById(id);
case 'reviews': return store.getReviewById(id);
default: return null; default: return null;
} }
} }
@@ -83,6 +88,17 @@ export function upsertResource(name, body) {
if (body.code) { const ex = store.getDiscountByCode(body.code); if (ex) { store.updateDiscount(ex.id, { ...ex, ...body }); return store.getDiscountById(ex.id); } } if (body.code) { const ex = store.getDiscountByCode(body.code); if (ex) { store.updateDiscount(ex.id, { ...ex, ...body }); return store.getDiscountById(ex.id); } }
const id = store.createDiscount(body); return store.getDiscountById(id); const id = store.createDiscount(body); return store.getDiscountById(id);
} }
if (name === 'reviews') {
if (body.id) {
if (body.approved !== undefined) store.setReviewApproved(body.id, body.approved ? 1 : 0);
return store.getReviewById(body.id);
}
const res = store.addReview({ product_slug: body.product_slug, name: body.name, rating: body.rating, text: body.text });
if (!res.ok) throw new Error(res.error || 'Ungültige Bewertung');
// optional direkt freigeben
if (body.approved) store.setReviewApproved(res.id, 1);
return store.getReviewById(res.id);
}
if (name === 'shipping_zones') { if (name === 'shipping_zones') {
if (body.id) { store.updateShippingZone(body.id, body); return store.getShippingZoneById(body.id); } if (body.id) { store.updateShippingZone(body.id, body); return store.getShippingZoneById(body.id); }
const id = store.createShippingZone(body); return store.getShippingZoneById(id); const id = store.createShippingZone(body); return store.getShippingZoneById(id);
@@ -103,6 +119,7 @@ export function deleteResource(name, id) {
case 'popups': store.deletePopup(id); return true; case 'popups': store.deletePopup(id); return true;
case 'discounts': store.deleteDiscount(id); return true; case 'discounts': store.deleteDiscount(id); return true;
case 'shipping_zones': store.deleteShippingZone(id); return true; case 'shipping_zones': store.deleteShippingZone(id); return true;
case 'reviews': store.deleteReview(id); return true;
default: throw new Error('Ressource nicht löschbar: ' + name); default: throw new Error('Ressource nicht löschbar: ' + name);
} }
} }
@@ -125,7 +142,7 @@ export function manifest(origin) {
ep.push({ method: 'POST', path: '/api/admin/pages/{id}/blocks', desc: 'Block-Array einer Seite setzen' }); ep.push({ method: 'POST', path: '/api/admin/pages/{id}/blocks', desc: 'Block-Array einer Seite setzen' });
return { return {
name: 'hd-commerce Admin API', name: 'hd-commerce Admin API',
version: '2.2.0', version: '2.3.0',
auth: 'Authorization: Bearer <HDC_API_TOKEN>', auth: 'Authorization: Bearer <HDC_API_TOKEN>',
base_url: origin || '', base_url: origin || '',
resources: RESOURCES, resources: RESOURCES,
@@ -140,7 +157,9 @@ export function manifest(origin) {
'discounts.value: bei percent 1100, bei fixed in Cent, bei freeshipping ignoriert. Codes werden case-insensitiv geprüft.', 'discounts.value: bei percent 1100, bei fixed in Cent, bei freeshipping ignoriert. Codes werden case-insensitiv geprüft.',
'shipping_zones.countries: CSV von ISO-Codes (DE, AT,CH) oder "EU" für alle EU-Länder. free_over_cents nullable.', 'shipping_zones.countries: CSV von ISO-Codes (DE, AT,CH) oder "EU" für alle EU-Länder. free_over_cents nullable.',
'products.mwst: 0, 7 oder 19. base_amount/base_unit/base_price_per ergeben den Grundpreis (PAngV), z. B. 250 + g + kg.', 'products.mwst: 0, 7 oder 19. base_amount/base_unit/base_price_per ergeben den Grundpreis (PAngV), z. B. 250 + g + kg.',
'Feature-Flags & payment_provider sind Settings-Keys (über /api/admin/settings setzbar): feature_newsletter, feature_accounts, , payment_provider (mollie|stripe|demo).', 'Feature-Flags & payment_provider sind Settings-Keys (über /api/admin/settings setzbar): feature_newsletter, feature_accounts, feature_reviews, feature_wishlist, feature_abandoned_cart, feature_search, payment_provider (mollie|stripe|demo).',
'reviews: POST ohne id legt eine Bewertung an (approved=0); POST mit { id, approved:true|false } moderiert. rating 15.',
'abandoned_carts ist nur lesbar; Versand-Trigger ist POST /api/cron/abandoned (Bearer CRON_TOKEN).',
], ],
}; };
} }
+2 -2
View File
@@ -88,8 +88,8 @@ export function currentUser(request) {
// --- Rollen-Gate --- // --- Rollen-Gate ---
// owner: alles · redaktion: Produkte/Inhalte/Marketing · versand: nur Bestellungen // owner: alles · redaktion: Produkte/Inhalte/Marketing · versand: nur Bestellungen
const ROLE_SECTIONS = { const ROLE_SECTIONS = {
owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'rabatte', 'inhalte', 'versandzonen', 'einstellungen', 'nutzer', 'audit'], owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'rabatte', 'inhalte', 'versandzonen', 'bewertungen', 'einstellungen', 'nutzer', 'audit'],
redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'rabatte', 'analytics', 'versandzonen'], redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'rabatte', 'analytics', 'versandzonen', 'bewertungen'],
versand: ['bestellungen'], versand: ['bestellungen'],
}; };
export function canAccess(role, section) { export function canAccess(role, section) {
+71
View File
@@ -0,0 +1,71 @@
// hd-commerce — Kunden-Session (getrennt von der Admin-Auth).
// Eigener Cookie-Name (hdc_customer), eigenes signiertes HMAC-Token. NICHT die Admin-Session.
import { createHmac, timingSafeEqual, randomBytes } from 'node:crypto';
import { getSetting, setSetting, getCustomerAccountById } from './store.js';
function resolveSecret() {
const env = (process.env.SESSION_SECRET || '').trim();
// Eigenes, vom Admin getrenntes Geheimnis ableiten, damit Admin- und Kunden-Token
// niemals gegeneinander gültig sind (anderer HMAC-Key).
let base = env && env.length >= 16 ? env : null;
if (!base) {
try {
let sec = getSetting('customer_session_secret');
if (!sec) { sec = randomBytes(32).toString('hex'); setSetting('customer_session_secret', sec); }
base = sec;
} catch { base = randomBytes(32).toString('hex'); }
}
return createHmac('sha256', base).update('hdc-customer-session').digest('hex');
}
const SECRET = resolveSecret();
export const CUSTOMER_COOKIE = 'hdc_customer';
function b64url(buf) { return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
function b64urlDecode(str) { return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); }
export function signCustomer(cid, maxAgeSeconds) {
const exp = Math.floor(Date.now() / 1000) + (maxAgeSeconds || 60 * 60 * 24 * 30);
const payload = b64url(JSON.stringify({ cid: Number(cid), exp }));
const sig = b64url(createHmac('sha256', SECRET).update(payload).digest());
return payload + '.' + sig;
}
export function verifyCustomerToken(token) {
if (!token || typeof token !== 'string' || !token.includes('.')) return null;
const [payload, sig] = token.split('.');
if (!payload || !sig) return null;
const expected = b64url(createHmac('sha256', SECRET).update(payload).digest());
try {
const a = Buffer.from(sig), b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
} catch { return null; }
let data;
try { data = JSON.parse(b64urlDecode(payload).toString('utf8')); } catch { return null; }
if (!data || !data.cid || !data.exp) return null;
if (data.exp < Math.floor(Date.now() / 1000)) return null;
return data;
}
const cookieSecure = () => (process.env.PUBLIC_BASE_URL || '').startsWith('https') || process.env.COOKIE_SECURE === '1';
export function buildCustomerCookie(token) {
const parts = [`${CUSTOMER_COOKIE}=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=' + (60 * 60 * 24 * 30)];
if (cookieSecure()) parts.push('Secure');
return parts.join('; ');
}
export function clearCustomerCookie() {
return `${CUSTOMER_COOKIE}=; Path=/; HttpOnly; SameSite=Lax;${cookieSecure() ? ' Secure;' : ''} Max-Age=0`;
}
function parseCookies(request) {
const h = request.headers.get('cookie') || '';
const out = {};
h.split(';').forEach(p => { const i = p.indexOf('='); if (i > -1) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); });
return out;
}
// Aktuell eingeloggter Kunde (oder null). Liest NUR den Kunden-Cookie.
export function currentCustomer(request) {
const token = parseCookies(request)[CUSTOMER_COOKIE];
const data = verifyCustomerToken(token);
if (!data) return null;
const c = getCustomerAccountById(data.cid);
return c || null;
}
+42
View File
@@ -118,3 +118,45 @@ export async function sendOrderConfirmation(order) {
const html = orderConfirmationHtml(order); const html = orderConfirmationHtml(order);
return sendMail({ to: order.email, subject, html, type: 'order_confirmation' }); return sendMail({ to: order.email, subject, html, type: 'order_confirmation' });
} }
// Gebrandete Warenkorb-Erinnerung (Abandoned-Cart). cart: { email, items, total_cents }
export function abandonedCartHtml(cart, baseUrl) {
const s = getSettings();
const shopName = s.shop_name || 'hd-commerce';
const accent = s.brand_accent || '#b8566a';
const items = Array.isArray(cart.items) ? cart.items : [];
const shopUrl = (baseUrl ? String(baseUrl).replace(/\/$/, '') : '') + '/warenkorb';
const rows = items.map(i => `
<tr>
<td style="padding:8px 0;border-bottom:1px solid #eee">${esc(i.name)}${i.size && i.size !== 'One Size' ? ' <span style="color:#888">(' + esc(i.size) + ')</span>' : ''}</td>
<td style="padding:8px 0;border-bottom:1px solid #eee;text-align:center;color:#555">${esc(i.qty || 1)}×</td>
<td style="padding:8px 0;border-bottom:1px solid #eee;text-align:right;white-space:nowrap">${esc(formatPrice((Number(i.priceCents) || 0) * (Number(i.qty) || 1)))}</td>
</tr>`).join('');
return `<!doctype html><html lang="de"><body style="margin:0;background:#f6f4f1;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#222">
<div style="max-width:560px;margin:0 auto;padding:24px">
<div style="background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.06)">
<div style="background:${esc(accent)};padding:22px 28px;color:#fff">
<div style="font-size:20px;font-weight:700">${esc(shopName)}</div>
<div style="opacity:.9;font-size:14px;margin-top:2px">Du hast etwas vergessen</div>
</div>
<div style="padding:28px">
<p style="margin:0 0 14px;font-size:16px">Dein Warenkorb wartet noch auf dich. Diese Artikel hast du dir angesehen:</p>
<table style="width:100%;border-collapse:collapse;font-size:14px"><tbody>${rows}</tbody></table>
<table style="width:100%;border-collapse:collapse;font-size:14px;margin-top:10px">
<tr><td style="padding:10px 0 0;font-weight:700;font-size:16px;border-top:1px solid #eee">Summe</td><td style="padding:10px 0 0;text-align:right;font-weight:700;font-size:16px;border-top:1px solid #eee">${esc(formatPrice(Number(cart.total_cents) || 0))}</td></tr>
</table>
<div style="margin-top:24px"><a href="${esc(shopUrl)}" style="display:inline-block;background:${esc(accent)};color:#fff;text-decoration:none;padding:13px 26px;border-radius:10px;font-weight:600">Warenkorb ansehen</a></div>
<p style="margin:22px 0 0;font-size:13px;color:#888">Falls du die Bestellung schon abgeschlossen hast, kannst du diese E-Mail ignorieren.</p>
</div>
</div>
<div style="text-align:center;color:#aaa;font-size:12px;margin-top:14px">© ${new Date().getFullYear()} ${esc(shopName)} · powered by hd-commerce</div>
</div></body></html>`;
}
export async function sendAbandonedCartReminder(cart, baseUrl) {
if (!cart || !cart.email) return { ok: false, status: 'no-recipient' };
const s = getSettings();
const subject = `Dein Warenkorb wartet · ${s.shop_name || 'hd-commerce'}`;
const html = abandonedCartHtml(cart, baseUrl);
return sendMail({ to: cart.email, subject, html, type: 'abandoned_cart' });
}
+191
View File
@@ -850,3 +850,194 @@ export function logEmail({ recipient = '', subject = '', html = '', type = 'gene
} }
export const listEmailLog = (limit = 100) => db.prepare('SELECT * FROM email_log ORDER BY id DESC LIMIT ?').all(Number(limit) || 100); export const listEmailLog = (limit = 100) => db.prepare('SELECT * FROM email_log ORDER BY id DESC LIMIT ?').all(Number(limit) || 100);
export const getEmailLogById = (id) => db.prepare('SELECT * FROM email_log WHERE id=?').get(Number(id)); export const getEmailLogById = (id) => db.prepare('SELECT * FROM email_log WHERE id=?').get(Number(id));
// =====================================================================
// v2.3 — Feature-Module: Suche, Kundenkonten/Adressen, Bewertungen, Abandoned-Cart
// =====================================================================
// ---------- neue Tabellen + Spalten ----------
db.exec(`
CREATE TABLE IF NOT EXISTS customer_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL,
name TEXT DEFAULT '', strasse TEXT DEFAULT '', plz TEXT DEFAULT '', ort TEXT DEFAULT '',
land TEXT DEFAULT 'DE', is_default INTEGER DEFAULT 0, created_at TEXT
);
CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT, product_slug TEXT NOT NULL, name TEXT DEFAULT '',
rating INTEGER DEFAULT 5, text TEXT DEFAULT '', approved INTEGER DEFAULT 0, created_at TEXT
);
CREATE TABLE IF NOT EXISTS abandoned_carts (
id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT, items_json TEXT DEFAULT '[]',
total_cents INTEGER DEFAULT 0, recovered INTEGER DEFAULT 0, reminded INTEGER DEFAULT 0, created_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_reviews_slug ON reviews(product_slug);
CREATE INDEX IF NOT EXISTS idx_addr_customer ON customer_addresses(customer_id);
`);
// Kundenkonten: Passwort-Hash + Zuordnung Bestellung→Kunde
ensureColumn('customers', 'pass_hash', "pass_hash TEXT");
ensureColumn('customers', 'pass_salt', "pass_salt TEXT");
ensureColumn('orders', 'customer_id', "customer_id INTEGER");
// ---------- Volltextsuche (feature_search) ----------
// LIKE/instr-basiert (case-insensitiv, ohne FTS-Tokenizer-Abhängigkeit), durchsucht
// Name, Kurzname, Beschreibung, Material und Kategorie.
export function searchProducts(query, limit = 60) {
const q = String(query || '').trim().toLowerCase();
if (!q) return [];
const like = '%' + q.replace(/[%_]/g, m => '\\' + m) + '%';
const rows = db.prepare(`
SELECT * FROM products
WHERE lower(name) LIKE ? ESCAPE '\\'
OR lower(COALESCE(shortName,'')) LIKE ? ESCAPE '\\'
OR lower(COALESCE(desc,'')) LIKE ? ESCAPE '\\'
OR lower(COALESCE(material,'')) LIKE ? ESCAPE '\\'
OR lower(COALESCE(category,'')) LIKE ? ESCAPE '\\'
ORDER BY
CASE WHEN lower(name) LIKE ? ESCAPE '\\' THEN 0 ELSE 1 END,
featured DESC, sort, id
LIMIT ?`).all(like, like, like, like, like, like, Number(limit) || 60);
return rows.map(P);
}
// ---------- Kundenkonten (feature_accounts) ----------
const CUST = (r) => r && ({ id: r.id, name: r.name, email: r.email, city: r.city, created_at: r.created_at, has_password: !!r.pass_hash });
export const getCustomerByEmail = (email) => db.prepare('SELECT * FROM customers WHERE LOWER(email)=LOWER(?)').get(String(email || '').trim());
export function registerCustomer({ name, email, password }) {
const e = String(email || '').toLowerCase().trim();
if (!e || !/.+@.+\..+/.test(e)) return { ok: false, error: 'Bitte gültige E-Mail angeben.' };
if (!password || String(password).length < 6) return { ok: false, error: 'Passwort muss mindestens 6 Zeichen haben.' };
const existing = getCustomerByEmail(e);
if (existing && existing.pass_hash) return { ok: false, error: 'Für diese E-Mail existiert bereits ein Konto.' };
const { pass_hash, pass_salt } = hashPassword(password);
const now = new Date().toISOString();
if (existing) {
db.prepare('UPDATE customers SET name=COALESCE(NULLIF(?,\'\'),name), pass_hash=?, pass_salt=? WHERE id=?')
.run(name || existing.name || '', pass_hash, pass_salt, existing.id);
return { ok: true, id: existing.id };
}
const r = db.prepare('INSERT INTO customers (name,email,city,created_at,pass_hash,pass_salt) VALUES (?,?,?,?,?,?)')
.run(name || e, e, '', now, pass_hash, pass_salt);
return { ok: true, id: r.lastInsertRowid };
}
export function verifyCustomer(email, password) {
const c = getCustomerByEmail(email);
if (!c || !c.pass_hash) return null;
if (!verifyPassword(password, c.pass_hash, c.pass_salt)) return null;
return CUST(c);
}
export const getCustomerAccountById = (id) => CUST(db.prepare('SELECT * FROM customers WHERE id=?').get(Number(id)));
// Adressbuch
const ADDR = (r) => r && ({ ...r, is_default: !!r.is_default });
export const listCustomerAddresses = (customerId) =>
db.prepare('SELECT * FROM customer_addresses WHERE customer_id=? ORDER BY is_default DESC, id DESC').all(Number(customerId)).map(ADDR);
export const getCustomerAddress = (id, customerId) =>
ADDR(db.prepare('SELECT * FROM customer_addresses WHERE id=? AND customer_id=?').get(Number(id), Number(customerId)));
export const defaultAddress = (customerId) =>
ADDR(db.prepare('SELECT * FROM customer_addresses WHERE customer_id=? ORDER BY is_default DESC, id DESC LIMIT 1').get(Number(customerId)));
export function addCustomerAddress(customerId, d) {
const cid = Number(customerId);
const isDefault = d.is_default ? 1 : 0;
if (isDefault) db.prepare('UPDATE customer_addresses SET is_default=0 WHERE customer_id=?').run(cid);
const haveAny = db.prepare('SELECT COUNT(*) c FROM customer_addresses WHERE customer_id=?').get(cid).c;
const r = db.prepare(`INSERT INTO customer_addresses (customer_id,name,strasse,plz,ort,land,is_default,created_at)
VALUES (?,?,?,?,?,?,?,?)`).run(cid, d.name || '', d.strasse || '', d.plz || '', d.ort || '',
String(d.land || 'DE').toUpperCase(), (isDefault || haveAny === 0) ? 1 : 0, new Date().toISOString());
return r.lastInsertRowid;
}
export function deleteCustomerAddress(id, customerId) {
db.prepare('DELETE FROM customer_addresses WHERE id=? AND customer_id=?').run(Number(id), Number(customerId));
return true;
}
export function setDefaultAddress(id, customerId) {
const cid = Number(customerId);
db.prepare('UPDATE customer_addresses SET is_default=0 WHERE customer_id=?').run(cid);
db.prepare('UPDATE customer_addresses SET is_default=1 WHERE id=? AND customer_id=?').run(Number(id), cid);
return true;
}
export const listOrdersForCustomer = (customerId, email) => {
const cid = Number(customerId);
return db.prepare('SELECT * FROM orders WHERE customer_id=? OR (? <> \'\' AND LOWER(email)=LOWER(?)) ORDER BY datetime(created_at) DESC, id DESC')
.all(cid, String(email || ''), String(email || '')).map(O);
};
export function attachOrderToCustomer(orderId, customerId) {
db.prepare('UPDATE orders SET customer_id=? WHERE id=?').run(Number(customerId), Number(orderId));
return true;
}
// ---------- Bewertungen (feature_reviews) ----------
const RV = (r) => r && ({ ...r, approved: !!r.approved });
export function addReview({ product_slug, name, rating, text }) {
const slug = String(product_slug || '').trim();
if (!slug) return { ok: false, error: 'Produkt fehlt.' };
const raw = Math.round(Number(rating));
if (!Number.isFinite(raw) || raw < 1 || raw > 5) return { ok: false, error: 'Bitte 1 bis 5 Sterne wählen.' };
const r = raw;
const res = db.prepare('INSERT INTO reviews (product_slug,name,rating,text,approved,created_at) VALUES (?,?,?,?,0,?)')
.run(slug, String(name || '').trim().slice(0, 80) || 'Anonym', r, String(text || '').trim().slice(0, 2000), new Date().toISOString());
return { ok: true, id: res.lastInsertRowid };
}
export const listApprovedReviews = (slug) =>
db.prepare('SELECT * FROM reviews WHERE product_slug=? AND approved=1 ORDER BY id DESC').all(String(slug || '')).map(RV);
export function reviewSummary(slug) {
const row = db.prepare('SELECT COUNT(*) c, COALESCE(AVG(rating),0) avg FROM reviews WHERE product_slug=? AND approved=1').get(String(slug || ''));
return { count: row.c, average: row.c ? Math.round(row.avg * 10) / 10 : 0 };
}
export const listAllReviews = (filter = 'all') => {
let where = '';
if (filter === 'pending') where = 'WHERE approved=0';
else if (filter === 'approved') where = 'WHERE approved=1';
return db.prepare(`SELECT * FROM reviews ${where} ORDER BY approved ASC, id DESC`).all().map(RV);
};
export const getReviewById = (id) => RV(db.prepare('SELECT * FROM reviews WHERE id=?').get(Number(id)));
export const countPendingReviews = () => db.prepare('SELECT COUNT(*) c FROM reviews WHERE approved=0').get().c;
export function setReviewApproved(id, approved) {
db.prepare('UPDATE reviews SET approved=? WHERE id=?').run(approved ? 1 : 0, Number(id));
return true;
}
export const deleteReview = (id) => db.prepare('DELETE FROM reviews WHERE id=?').run(Number(id));
// API-Mapper: Upsert/CRUD über admin-api
export const listReviews = () => db.prepare('SELECT * FROM reviews ORDER BY id DESC').all().map(RV);
// ---------- Abandoned Carts (feature_abandoned_cart) ----------
const AC = (r) => r && ({ ...r, items: (() => { try { return JSON.parse(r.items_json || '[]'); } catch { return []; } })(), recovered: !!r.recovered, reminded: !!r.reminded });
export function captureAbandonedCart({ email, items, total_cents }) {
const e = String(email || '').toLowerCase().trim();
if (!e || !/.+@.+\..+/.test(e)) return { ok: false, error: 'E-Mail fehlt.' };
const its = Array.isArray(items) ? items : [];
if (!its.length) return { ok: false, error: 'Warenkorb leer.' };
const total = Math.round(Number(total_cents) || its.reduce((s, i) => s + (Number(i.priceCents) || 0) * (Number(i.qty) || 1), 0));
// Offene (nicht recovered) Karte derselben E-Mail aktualisieren statt duplizieren.
const open = db.prepare("SELECT * FROM abandoned_carts WHERE LOWER(email)=LOWER(?) AND recovered=0 AND reminded=0 ORDER BY id DESC LIMIT 1").get(e);
if (open) {
db.prepare('UPDATE abandoned_carts SET items_json=?, total_cents=?, created_at=? WHERE id=?')
.run(JSON.stringify(its), total, new Date().toISOString(), open.id);
return { ok: true, id: open.id, updated: true };
}
const r = db.prepare('INSERT INTO abandoned_carts (email,items_json,total_cents,recovered,reminded,created_at) VALUES (?,?,?,0,0,?)')
.run(e, JSON.stringify(its), total, new Date().toISOString());
return { ok: true, id: r.lastInsertRowid };
}
export const listAbandonedCarts = (limit = 100) => db.prepare('SELECT * FROM abandoned_carts ORDER BY id DESC LIMIT ?').all(Number(limit) || 100).map(AC);
export function abandonedCartStats() {
const total = db.prepare('SELECT COUNT(*) c FROM abandoned_carts').get().c;
const open = db.prepare('SELECT COUNT(*) c FROM abandoned_carts WHERE recovered=0 AND reminded=0').get().c;
const reminded = db.prepare('SELECT COUNT(*) c FROM abandoned_carts WHERE reminded=1').get().c;
const recovered = db.prepare('SELECT COUNT(*) c FROM abandoned_carts WHERE recovered=1').get().c;
return { total, open, reminded, recovered };
}
// Karten älter als olderThanMinutes, weder recovered noch reminded.
export function dueAbandonedCarts(olderThanMinutes = 30) {
const cutoff = new Date(Date.now() - Math.max(0, Number(olderThanMinutes) || 0) * 60000).toISOString();
return db.prepare("SELECT * FROM abandoned_carts WHERE recovered=0 AND reminded=0 AND created_at <= ? ORDER BY id ASC").all(cutoff).map(AC);
}
export const markAbandonedReminded = (id) => { db.prepare('UPDATE abandoned_carts SET reminded=1 WHERE id=?').run(Number(id)); return true; };
// Bei erfolgreicher Bestellung: offene Karten dieser E-Mail als recovered markieren.
export function markCartRecoveredByEmail(email) {
const e = String(email || '').toLowerCase().trim();
if (!e) return 0;
const r = db.prepare("UPDATE abandoned_carts SET recovered=1 WHERE LOWER(email)=LOWER(?) AND recovered=0").run(e);
return r.changes || 0;
}
+1 -1
View File
@@ -18,7 +18,7 @@ function sectionOf(adminInner) {
const map = { const map = {
'': 'dashboard', 'bestellungen': 'bestellungen', 'produkte': 'produkte', 'kunden': 'kunden', '': 'dashboard', 'bestellungen': 'bestellungen', 'produkte': 'produkte', 'kunden': 'kunden',
'analytics': 'analytics', 'marketing': 'marketing', 'rabatte': 'rabatte', 'inhalte': 'inhalte', 'einstellungen': 'einstellungen', 'analytics': 'analytics', 'marketing': 'marketing', 'rabatte': 'rabatte', 'inhalte': 'inhalte', 'einstellungen': 'einstellungen',
'nutzer': 'nutzer', 'audit': 'audit', 'versand': 'versandzonen', 'konto': 'dashboard', 'login': 'login', 'logout': 'logout', 'nutzer': 'nutzer', 'audit': 'audit', 'versand': 'versandzonen', 'bewertungen': 'bewertungen', 'konto': 'dashboard', 'login': 'login', 'logout': 'logout',
}; };
return map[seg] || 'dashboard'; return map[seg] || 'dashboard';
} }
+71
View File
@@ -0,0 +1,71 @@
---
import Admin from '../../../layouts/Admin.astro';
import { adminBase, currentUser, canAccess } from '../../../lib/auth.js';
import { feature, listAllReviews, setReviewApproved, deleteReview, getProductBySlug } from '../../../lib/store.js';
const base = adminBase();
const user = currentUser(Astro.request);
// Modul aus → Bereich existiert nicht.
if (!feature('feature_reviews')) return Astro.redirect(base + '/einstellungen');
if (!user || !canAccess(user.role, 'bewertungen')) return Astro.redirect(base);
let flash = '';
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData();
const action = String(f.get('_action') || '');
const id = f.get('id');
if (action === 'approve') { setReviewApproved(id, true); flash = 'Bewertung freigegeben.'; }
else if (action === 'reject') { setReviewApproved(id, false); flash = 'Bewertung abgelehnt (nicht öffentlich).'; }
else if (action === 'delete') { deleteReview(id); flash = 'Bewertung gelöscht.'; }
}
const url = new URL(Astro.request.url);
const tab = url.searchParams.get('tab') || 'pending';
const reviews = listAllReviews(tab === 'all' ? 'all' : tab === 'approved' ? 'approved' : 'pending');
const counts = {
pending: listAllReviews('pending').length,
approved: listAllReviews('approved').length,
all: listAllReviews('all').length,
};
function stars(n) { return '★★★★★'.slice(0, n) + '☆☆☆☆☆'.slice(0, 5 - n); }
---
<Admin title="Bewertungen" active="bewertungen" crumbs={[{ label: 'Bewertungen' }]}>
<div class="s-stack">
{flash && <div class="s-flash">✓ {flash}</div>}
<div class="s-tabs">
<a class={`s-tab ${tab === 'pending' ? 'active' : ''}`} href={base + '/bewertungen?tab=pending'}>Wartet auf Freigabe ({counts.pending})</a>
<a class={`s-tab ${tab === 'approved' ? 'active' : ''}`} href={base + '/bewertungen?tab=approved'}>Freigegeben ({counts.approved})</a>
<a class={`s-tab ${tab === 'all' ? 'active' : ''}`} href={base + '/bewertungen?tab=all'}>Alle ({counts.all})</a>
</div>
{reviews.length === 0 ? (
<div class="s-card s-card-pad"><p class="s-help" style="margin:0">Keine Bewertungen in dieser Ansicht.</p></div>
) : (
<div class="s-card">
<table class="s-table">
<thead><tr><th>Produkt</th><th>Bewertung</th><th>Text</th><th>Datum</th><th>Status</th><th style="text-align:right">Aktionen</th></tr></thead>
<tbody>
{reviews.map((r) => {
const p = getProductBySlug(r.product_slug);
return (
<tr>
<td>{p ? (<a href={`/produkt/${r.product_slug}`} target="_blank" class="s-link">{p.shortName || p.name}</a>) : r.product_slug}</td>
<td><span style="color:var(--accent);letter-spacing:1px" title={`${r.rating}/5`}>{stars(r.rating)}</span><div style="font-size:12px;color:var(--faint)">{r.name}</div></td>
<td style="max-width:320px">{r.text || <span style="color:var(--faint)">—</span>}</td>
<td style="white-space:nowrap">{new Date(r.created_at).toLocaleDateString('de-DE')}</td>
<td>{r.approved ? <span class="s-badge green">Freigegeben</span> : <span class="s-badge amber">Wartet</span>}</td>
<td style="text-align:right;white-space:nowrap">
{!r.approved && (<form method="POST" style="display:inline"><input type="hidden" name="_action" value="approve" /><input type="hidden" name="id" value={r.id} /><button class="s-btn s-btn-sm s-btn-primary" type="submit">Freigeben</button></form>)}
{r.approved && (<form method="POST" style="display:inline"><input type="hidden" name="_action" value="reject" /><input type="hidden" name="id" value={r.id} /><button class="s-btn s-btn-sm" type="submit">Verbergen</button></form>)}
{' '}
<form method="POST" style="display:inline" onsubmit="return confirm('Bewertung endgültig löschen?')"><input type="hidden" name="_action" value="delete" /><input type="hidden" name="id" value={r.id} /><button class="s-btn s-btn-sm s-btn-danger" type="submit">Löschen</button></form>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</Admin>
+24 -6
View File
@@ -2,16 +2,16 @@
import Admin from '../../../layouts/Admin.astro'; import Admin from '../../../layouts/Admin.astro';
import { adminBase } from '../../../lib/auth.js'; import { adminBase } from '../../../lib/auth.js';
const base = adminBase(); const base = adminBase();
import { getSettings, setSetting, resolvePaymentProvider, FEATURE_KEYS, feature } from '../../../lib/store.js'; import { getSettings, setSetting, resolvePaymentProvider, FEATURE_KEYS, feature, abandonedCartStats, countPendingReviews } from '../../../lib/store.js';
import { mailerStatus } from '../../../lib/mailer.js'; import { mailerStatus } from '../../../lib/mailer.js';
const FEATURE_LABELS = { const FEATURE_LABELS = {
feature_newsletter: ['Newsletter', 'Newsletter-Popup & Anmeldung im Storefront'], feature_newsletter: ['Newsletter', 'Newsletter-Popup & Anmeldung im Storefront'],
feature_accounts: ['Kundenkonten', 'Registrierung & Login für Kund:innen (in Vorbereitung)'], feature_accounts: ['Kundenkonten', 'Registrierung, Login, Bestellhistorie & Adressbuch für Kund:innen; Adresse im Checkout vorbefüllt'],
feature_reviews: ['Bewertungen', 'Produktbewertungen (in Vorbereitung)'], feature_reviews: ['Bewertungen', 'Sterne-Bewertungen auf Produktseiten mit Moderation (Freigabe im Admin)'],
feature_wishlist: ['Merkliste', 'Wunschliste / Merken (in Vorbereitung)'], feature_wishlist: ['Merkliste', 'Herz-Button auf Produktkarten & Detailseite; Merkliste unter /merkliste (clientseitig)'],
feature_abandoned_cart: ['Warenkorb-Erinnerung', 'Abandoned-Cart-Mails (in Vorbereitung)'], feature_abandoned_cart: ['Warenkorb-Erinnerung', 'Begonnene Checkouts speichern & per Mail erinnern (Cron /api/cron/abandoned)'],
feature_search: ['Suche', 'Produktsuche im Storefront (in Vorbereitung)'], feature_search: ['Suche', 'Volltextsuche im Storefront-Header mit Ergebnisseite /suche'],
}; };
let flash = ''; let flash = '';
@@ -47,6 +47,9 @@ const stripeSet = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test((process.env.STRIPE_SE
const providerSetting = s.payment_provider || ''; const providerSetting = s.payment_provider || '';
const mail = mailerStatus(); const mail = mailerStatus();
const acStats = abandonedCartStats();
const pendingReviews = countPendingReviews();
const cronToken = (process.env.CRON_TOKEN || '').trim();
--- ---
<Admin title="Einstellungen" active="einstellungen" crumbs={[{ label: 'Einstellungen' }]}> <Admin title="Einstellungen" active="einstellungen" crumbs={[{ label: 'Einstellungen' }]}>
<div class="s-stack"> <div class="s-stack">
@@ -111,6 +114,21 @@ const mail = mailerStatus();
<div class="s-section-title" style="margin-bottom:12px">System</div> <div class="s-section-title" style="margin-bottom:12px">System</div>
<p class="s-help">Datenbank: SQLite (<b>DB_PATH</b>). Admin-Zugang über Session-Login; Initial-Owner aus <b>ADMIN_EMAIL</b> / <b>ADMIN_PASS</b>. Admin-Pfad über <b>ADMIN_PATH</b>.</p> <p class="s-help">Datenbank: SQLite (<b>DB_PATH</b>). Admin-Zugang über Session-Login; Initial-Owner aus <b>ADMIN_EMAIL</b> / <b>ADMIN_PASS</b>. Admin-Pfad über <b>ADMIN_PATH</b>.</p>
</div> </div>
{feature('feature_abandoned_cart') && (
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:12px">Warenkorb-Erinnerung</div>
<p class="s-help" style="margin-bottom:6px">Gespeicherte Warenkörbe: <b>{acStats.total}</b> · offen <b>{acStats.open}</b> · erinnert <b>{acStats.reminded}</b> · wiederhergestellt <b>{acStats.recovered}</b></p>
<p class="s-help">Versand-Trigger: <b>POST /api/cron/abandoned</b> (Header <code>Authorization: Bearer &lt;CRON_TOKEN&gt;</code> oder <code>?token=</code>). {cronToken ? 'CRON_TOKEN gesetzt.' : 'CRON_TOKEN noch nicht gesetzt — Endpoint bleibt gesperrt.'}</p>
</div>
)}
{feature('feature_reviews') && pendingReviews > 0 && (
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:8px">Bewertungen</div>
<p class="s-help"><b>{pendingReviews}</b> Bewertung(en) warten auf Freigabe — <a href={base + '/bewertungen'}>jetzt prüfen</a>.</p>
</div>
)}
</div> </div>
</form> </form>
+14
View File
@@ -0,0 +1,14 @@
import { captureAbandonedCart, feature } from '../../lib/store.js';
export const prerender = false;
function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); }
// Wird beim Checkout-Start aufgerufen, sobald eine E-Mail vorliegt. Speichert den Warenkorb
// serverseitig, damit eine Erinnerungsmail verschickt werden kann.
export async function POST({ request }) {
if (!feature('feature_abandoned_cart')) return json({ ok: false, error: 'Modul deaktiviert.' }, 404);
let b;
try { b = await request.json(); } catch { return json({ ok: false, error: 'Bad request' }, 400); }
const res = captureAbandonedCart({ email: b.email, items: b.items, total_cents: b.total_cents });
if (!res.ok) return json(res, 400);
return json({ ok: true, id: res.id });
}
+10
View File
@@ -1,7 +1,9 @@
import { import {
createOrder, getSetting, validateDiscount, redeemDiscount, bestAutoDiscount, createOrder, getSetting, validateDiscount, redeemDiscount, bestAutoDiscount,
shippingFor, taxFromGross, getProductBySlug, markOrderPaid, getOrderById, setOrderPayment, shippingFor, taxFromGross, getProductBySlug, markOrderPaid, getOrderById, setOrderPayment,
feature, getCustomerByEmail, attachOrderToCustomer, markCartRecoveredByEmail,
} from '../../lib/store.js'; } from '../../lib/store.js';
import { currentCustomer } from '../../lib/customer-auth.js';
import { createPayment } from '../../lib/payments.js'; import { createPayment } from '../../lib/payments.js';
import { sendOrderConfirmation } from '../../lib/mailer.js'; import { sendOrderConfirmation } from '../../lib/mailer.js';
export const prerender = false; export const prerender = false;
@@ -93,6 +95,13 @@ export async function POST({ request }) {
redeemDiscount(discount.id, discount.code, email, order.id, discount.freeShipping ? 0 : discountCents); redeemDiscount(discount.id, discount.code, email, order.id, discount.freeShipping ? 0 : discountCents);
} }
// Kundenkonten: Bestellung dem eingeloggten Konto (oder dem Konto zur E-Mail) zuordnen.
if (feature('feature_accounts')) {
let cust = currentCustomer(request);
if (!cust && email) { const byEmail = getCustomerByEmail(email); if (byEmail) cust = byEmail; }
if (cust) { try { attachOrderToCustomer(order.id, cust.id); } catch {} }
}
const origin = publicBase(request); const origin = publicBase(request);
const returnUrl = `${origin}/bestellung-erfolgreich?order=${order.number}`; const returnUrl = `${origin}/bestellung-erfolgreich?order=${order.number}`;
const pay = await createPayment({ const pay = await createPayment({
@@ -107,6 +116,7 @@ export async function POST({ request }) {
if (pay.provider === 'demo') { if (pay.provider === 'demo') {
const res = markOrderPaid(order.id, { payment_provider: 'demo', payment_id: '' }); const res = markOrderPaid(order.id, { payment_provider: 'demo', payment_id: '' });
if (res.changed) { try { await sendOrderConfirmation(res.order); } catch {} } if (res.changed) { try { await sendOrderConfirmation(res.order); } catch {} }
if (feature('feature_abandoned_cart') && email) { try { markCartRecoveredByEmail(email); } catch {} }
} else if (pay.paymentId) { } else if (pay.paymentId) {
// Payment-ID an der Order vermerken, damit der Webhook sie zuordnen kann. // Payment-ID an der Order vermerken, damit der Webhook sie zuordnen kann.
setOrderPayment(order.id, { payment_id: pay.paymentId, payment_provider: pay.provider }); setOrderPayment(order.id, { payment_id: pay.paymentId, payment_provider: pay.provider });
+48
View File
@@ -0,0 +1,48 @@
import { dueAbandonedCarts, markAbandonedReminded, feature } from '../../../lib/store.js';
import { sendAbandonedCartReminder } from '../../../lib/mailer.js';
import { timingSafeEqual } from 'node:crypto';
export const prerender = false;
function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); }
function publicBase(request) {
const env = (process.env.PUBLIC_BASE_URL || '').trim().replace(/\/$/, '');
if (env) return env;
const proto = request.headers.get('x-forwarded-proto') || 'https';
const host = request.headers.get('x-forwarded-host') || request.headers.get('host');
if (host) return `${proto}://${host}`;
try { return new URL(request.url).origin; } catch { return ''; }
}
function tokenOk(request, url) {
const token = (process.env.CRON_TOKEN || '').trim();
if (!token) return false; // ohne CRON_TOKEN bleibt der Endpoint gesperrt
const hdr = request.headers.get('authorization') || '';
const m = hdr.match(/^Bearer\s+(.+)$/i);
const provided = (m ? m[1].trim() : '') || (url.searchParams.get('token') || '').trim();
if (!provided) return false;
const a = Buffer.from(provided), b = Buffer.from(token);
return a.length === b.length && timingSafeEqual(a, b);
}
async function run({ request, url }) {
if (!feature('feature_abandoned_cart')) return json({ ok: false, error: 'Modul deaktiviert.' }, 404);
if (!tokenOk(request, url)) return json({ ok: false, error: 'Unauthorized' }, 401);
const rawMin = url.searchParams.get('minutes');
const minSrc = rawMin != null && rawMin !== '' ? rawMin : (process.env.ABANDONED_AFTER_MINUTES || '30');
const parsedMin = parseInt(minSrc, 10);
const minutes = Math.max(0, Number.isFinite(parsedMin) ? parsedMin : 30);
const base = publicBase(request);
const due = dueAbandonedCarts(minutes);
let sent = 0;
for (const cart of due) {
try {
const res = await sendAbandonedCartReminder(cart, base);
markAbandonedReminded(cart.id);
if (res && res.ok) sent++;
} catch { markAbandonedReminded(cart.id); }
}
return json({ ok: true, due: due.length, reminded: sent, older_than_minutes: minutes });
}
export const POST = (ctx) => run({ request: ctx.request, url: new URL(ctx.request.url) });
export const GET = (ctx) => run({ request: ctx.request, url: new URL(ctx.request.url) });
+7 -1
View File
@@ -28,7 +28,13 @@ export async function POST({ request }) {
} }
if (order) { if (order) {
const res = markOrderPaid(order.id, { payment_id: paymentId, payment_provider: 'mollie' }); const res = markOrderPaid(order.id, { payment_id: paymentId, payment_provider: 'mollie' });
if (res.changed) { try { await sendOrderConfirmation(res.order); } catch {} } if (res.changed) {
try { await sendOrderConfirmation(res.order); } catch {}
try {
const { feature, markCartRecoveredByEmail } = await import('../../../lib/store.js');
if (feature('feature_abandoned_cart') && res.order && res.order.email) markCartRecoveredByEmail(res.order.email);
} catch {}
}
} }
} }
} catch (e) { } catch (e) {
+12
View File
@@ -0,0 +1,12 @@
import { addReview, feature } from '../../lib/store.js';
export const prerender = false;
function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); }
export async function POST({ request }) {
if (!feature('feature_reviews')) return json({ ok: false, error: 'Bewertungen sind deaktiviert.' }, 404);
let b;
try { b = await request.json(); } catch { return json({ ok: false, error: 'Bad request' }, 400); }
const res = addReview({ product_slug: b.product_slug, name: b.name, rating: b.rating, text: b.text });
if (!res.ok) return json(res, 400);
return json({ ok: true });
}
+25 -8
View File
@@ -1,6 +1,7 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import { getSetting, listActiveShippingZones, resolvePaymentProvider } from '../lib/store.js'; import { getSetting, listActiveShippingZones, resolvePaymentProvider, feature, defaultAddress } from '../lib/store.js';
import { currentCustomer } from '../lib/customer-auth.js';
const currency = getSetting('currency', 'EUR'); const currency = getSetting('currency', 'EUR');
const zones = listActiveShippingZones(); const zones = listActiveShippingZones();
// Länder-Optionen aus aktiven Zonen ableiten (ISO-Codes + Klartext). // Länder-Optionen aus aktiven Zonen ableiten (ISO-Codes + Klartext).
@@ -19,6 +20,15 @@ countrySet.sort((a, b) => (a.code === 'DE' ? -1 : b.code === 'DE' ? 1 : a.name.l
const pp = resolvePaymentProvider(); const pp = resolvePaymentProvider();
const providerLabel = { mollie: 'Mollie', stripe: 'Stripe', demo: 'Demo' }[pp.provider] || pp.provider; const providerLabel = { mollie: 'Mollie', stripe: 'Stripe', demo: 'Demo' }[pp.provider] || pp.provider;
const demoMode = pp.provider === 'demo'; const demoMode = pp.provider === 'demo';
// Eingeloggter Kunde? Adresse vorbefüllen (Konto-Modul).
const accountsOn = feature('feature_accounts');
const customer = accountsOn ? currentCustomer(Astro.request) : null;
const pre = customer ? (defaultAddress(customer.id) || {}) : {};
const preName = (pre.name || customer?.name || '').trim();
const preFirst = preName ? preName.split(' ')[0] : '';
const preLast = preName ? preName.split(' ').slice(1).join(' ') : '';
const abandonedOn = feature('feature_abandoned_cart');
--- ---
<Base title="Kasse"> <Base title="Kasse">
<div class="wrap"> <div class="wrap">
@@ -27,13 +37,14 @@ const demoMode = pp.provider === 'demo';
<div class="cart-wrap" style="align-items:start"> <div class="cart-wrap" style="align-items:start">
<form id="coForm"> <form id="coForm">
<h3 style="margin-bottom:16px">Kontakt & Lieferadresse</h3> <h3 style="margin-bottom:16px">Kontakt & Lieferadresse</h3>
{accountsOn && !customer && (<p style="margin:-8px 0 14px;font-size:14px;color:var(--faint)">Bereits Kund:in? <a href="/konto/anmelden">Anmelden</a> für vorausgefüllte Adresse.</p>)}
<div class="form-grid"> <div class="form-grid">
<div class="field"><label>Vorname</label><input name="vorname" required /></div> <div class="field"><label>Vorname</label><input name="vorname" value={preFirst} required /></div>
<div class="field"><label>Nachname</label><input name="nachname" required /></div> <div class="field"><label>Nachname</label><input name="nachname" value={preLast} required /></div>
<div class="field full"><label>E-Mail</label><input name="email" type="email" required /></div> <div class="field full"><label>E-Mail</label><input name="email" type="email" value={customer?.email || ''} required /></div>
<div class="field full"><label>Straße & Hausnummer</label><input name="strasse" required /></div> <div class="field full"><label>Straße & Hausnummer</label><input name="strasse" value={pre.strasse || ''} required /></div>
<div class="field"><label>PLZ</label><input name="plz" required /></div> <div class="field"><label>PLZ</label><input name="plz" value={pre.plz || ''} required /></div>
<div class="field"><label>Ort</label><input name="ort" required /></div> <div class="field"><label>Ort</label><input name="ort" value={pre.ort || ''} required /></div>
<div class="field full"><label>Land</label> <div class="field full"><label>Land</label>
<select name="country" id="coCountry"> <select name="country" id="coCountry">
{countrySet.map((c) => (<option value={c.code} selected={c.code === 'DE'}>{c.name}</option>))} {countrySet.map((c) => (<option value={c.code} selected={c.code === 'DE'}>{c.name}</option>))}
@@ -57,7 +68,7 @@ const demoMode = pp.provider === 'demo';
</div> </div>
</div> </div>
<script is:inline define:vars={{ currency }}> <script is:inline define:vars={{ currency, abandonedOn }}>
(function () { (function () {
var code = ''; // aktiver Gutscheincode var code = ''; // aktiver Gutscheincode
var quote = null; // letztes Server-Quote { shippingCents, taxCents, taxByRate, discountCents, freeShipping, total, ... } var quote = null; // letztes Server-Quote { shippingCents, taxCents, taxByRate, discountCents, freeShipping, total, ... }
@@ -125,6 +136,12 @@ const demoMode = pp.provider === 'demo';
var items = window.HDC.read(); var items = window.HDC.read();
if (!items.length) { document.getElementById('coMsg').textContent = 'Dein Warenkorb ist leer.'; return; } if (!items.length) { document.getElementById('coMsg').textContent = 'Dein Warenkorb ist leer.'; return; }
window.HDC.track('checkout_start', window.HDC.subtotal()); window.HDC.track('checkout_start', window.HDC.subtotal());
if (abandonedOn) {
try {
var capEmail = (document.querySelector('[name=email]') || {}).value || '';
if (capEmail) fetch('/api/cart-capture', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ email: capEmail, items: items, total_cents: window.HDC.subtotal() }) }).catch(function(){});
} catch (e) {}
}
var btn = document.getElementById('coBtn'); btn.disabled = true; btn.textContent = 'Wird verarbeitet …'; var btn = document.getElementById('coBtn'); btn.disabled = true; btn.textContent = 'Wird verarbeitet …';
var fd = new FormData(f), contact = {}; fd.forEach(function (v, k) { contact[k] = v; }); var fd = new FormData(f), contact = {}; fd.forEach(function (v, k) { contact[k] = v; });
fetch('/api/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: items, contact: contact, code: code, country: country() }) }) fetch('/api/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: items, contact: contact, code: code, country: country() }) })
+4
View File
@@ -0,0 +1,4 @@
---
import { clearCustomerCookie } from '../../lib/customer-auth.js';
return new Response(null, { status: 302, headers: { 'Location': '/', 'Set-Cookie': clearCustomerCookie() } });
---
+37
View File
@@ -0,0 +1,37 @@
---
import Base from '../../layouts/Base.astro';
import { feature, verifyCustomer } from '../../lib/store.js';
import { signCustomer, buildCustomerCookie, currentCustomer } from '../../lib/customer-auth.js';
if (!feature('feature_accounts')) return Astro.redirect('/');
if (currentCustomer(Astro.request)) return Astro.redirect('/konto');
let error = '';
let email = '';
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData();
email = String(f.get('email') || '');
const password = String(f.get('password') || '');
const c = verifyCustomer(email, password);
if (c) {
const token = signCustomer(c.id);
return new Response(null, { status: 302, headers: { 'Location': '/konto', 'Set-Cookie': buildCustomerCookie(token) } });
}
error = 'E-Mail oder Passwort ist falsch.';
}
---
<Base title="Anmelden">
<div class="wrap">
<div class="auth-card">
<h1>Anmelden</h1>
<p style="color:var(--faint);margin:0 0 22px">Willkommen zurück.</p>
{error && <div class="auth-error">{error}</div>}
<form method="POST">
<div class="field"><label>E-Mail</label><input name="email" type="email" value={email} required /></div>
<div class="field"><label>Passwort</label><input name="password" type="password" required /></div>
<button class="btn btn-primary btn-lg btn-block" type="submit" style="margin-top:8px">Anmelden</button>
</form>
<p style="margin-top:18px;font-size:14px">Noch kein Konto? <a href="/konto/registrieren">Jetzt registrieren</a></p>
</div>
</div>
</Base>
+109
View File
@@ -0,0 +1,109 @@
---
import Base from '../../layouts/Base.astro';
import { feature, formatPrice, listOrdersForCustomer, listCustomerAddresses, addCustomerAddress, deleteCustomerAddress, setDefaultAddress } from '../../lib/store.js';
import { currentCustomer } from '../../lib/customer-auth.js';
if (!feature('feature_accounts')) return Astro.redirect('/');
const customer = currentCustomer(Astro.request);
if (!customer) return Astro.redirect('/konto/anmelden');
let flash = '';
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData();
const action = String(f.get('_action') || '');
if (action === 'add_address') {
addCustomerAddress(customer.id, {
name: f.get('name'), strasse: f.get('strasse'), plz: f.get('plz'),
ort: f.get('ort'), land: f.get('land') || 'DE', is_default: f.get('is_default') === 'on',
});
flash = 'Adresse gespeichert.';
} else if (action === 'del_address') {
deleteCustomerAddress(f.get('id'), customer.id);
flash = 'Adresse entfernt.';
} else if (action === 'default_address') {
setDefaultAddress(f.get('id'), customer.id);
flash = 'Standardadresse gesetzt.';
}
}
const orders = listOrdersForCustomer(customer.id, customer.email);
const addresses = listCustomerAddresses(customer.id);
const COUNTRY_NAMES = { DE: 'Deutschland', AT: 'Österreich', CH: 'Schweiz', NL: 'Niederlande', BE: 'Belgien', FR: 'Frankreich', LU: 'Luxemburg', IT: 'Italien', ES: 'Spanien', PL: 'Polen', DK: 'Dänemark' };
const statusLabel = { pending: 'In Bearbeitung', paid: 'Bezahlt', fulfilled: 'Versandt', cancelled: 'Storniert', refunded: 'Erstattet' };
---
<Base title="Mein Konto">
<div class="wrap">
<div class="konto-head">
<div>
<span class="eyebrow">Mein Konto</span>
<h1 style="margin-top:8px">Hallo {customer.name ? customer.name.split(' ')[0] : ''}</h1>
<p style="color:var(--faint);margin:6px 0 0">{customer.email}</p>
</div>
<a class="btn btn-ghost" href="/konto/abmelden">Abmelden</a>
</div>
{flash && <div class="auth-flash">{flash}</div>}
<section class="section">
<h2 style="margin-bottom:16px">Bestellungen</h2>
{orders.length === 0 ? (
<p style="color:var(--faint)">Du hast noch keine Bestellungen. <a href="/shop">Zum Shop</a></p>
) : (
<div class="konto-orders">
{orders.map((o) => (
<div class="konto-order">
<div class="ko-top">
<span class="ko-num">{o.number}</span>
<span class={`ko-status ko-${o.status}`}>{statusLabel[o.status] || o.status}</span>
</div>
<div class="ko-date">{new Date(o.created_at).toLocaleDateString('de-DE')}</div>
<div class="ko-items">{o.items.map((i) => `${i.qty}× ${i.name}`).join(' · ')}</div>
<div class="ko-total">{formatPrice(o.total_cents)}</div>
</div>
))}
</div>
)}
</section>
<section class="section">
<h2 style="margin-bottom:16px">Adressbuch</h2>
{addresses.length > 0 && (
<div class="konto-addr-grid">
{addresses.map((a) => (
<div class={`konto-addr ${a.is_default ? 'is-default' : ''}`}>
{a.is_default && <span class="addr-default-badge">Standard</span>}
<div class="addr-name">{a.name}</div>
<div class="addr-lines">{a.strasse}<br />{a.plz} {a.ort}<br />{COUNTRY_NAMES[a.land] || a.land}</div>
<div class="addr-actions">
{!a.is_default && (
<form method="POST" style="display:inline"><input type="hidden" name="_action" value="default_address" /><input type="hidden" name="id" value={a.id} /><button class="addr-link" type="submit">Als Standard</button></form>
)}
<form method="POST" style="display:inline" onsubmit="return confirm('Adresse löschen?')"><input type="hidden" name="_action" value="del_address" /><input type="hidden" name="id" value={a.id} /><button class="addr-link danger" type="submit">Löschen</button></form>
</div>
</div>
))}
</div>
)}
<details class="addr-add" {...(addresses.length === 0 ? { open: true } : {})}>
<summary>Neue Adresse hinzufügen</summary>
<form method="POST" class="addr-form">
<input type="hidden" name="_action" value="add_address" />
<div class="form-grid">
<div class="field full"><label>Name</label><input name="name" placeholder="Vor- und Nachname" required /></div>
<div class="field full"><label>Straße & Hausnummer</label><input name="strasse" required /></div>
<div class="field"><label>PLZ</label><input name="plz" required /></div>
<div class="field"><label>Ort</label><input name="ort" required /></div>
<div class="field full"><label>Land</label>
<select name="land">
{Object.entries(COUNTRY_NAMES).map(([code, nm]) => (<option value={code} selected={code === 'DE'}>{nm}</option>))}
</select>
</div>
<label class="field full" style="flex-direction:row;align-items:center;gap:8px"><input type="checkbox" name="is_default" style="width:auto" /> Als Standardadresse verwenden</label>
</div>
<button class="btn btn-primary" type="submit">Adresse speichern</button>
</form>
</details>
</section>
</div>
</Base>
+38
View File
@@ -0,0 +1,38 @@
---
import Base from '../../layouts/Base.astro';
import { feature, registerCustomer } from '../../lib/store.js';
import { signCustomer, buildCustomerCookie, currentCustomer } from '../../lib/customer-auth.js';
if (!feature('feature_accounts')) return Astro.redirect('/');
if (currentCustomer(Astro.request)) return Astro.redirect('/konto');
let error = '';
let values = { name: '', email: '' };
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData();
values = { name: String(f.get('name') || ''), email: String(f.get('email') || '') };
const password = String(f.get('password') || '');
const res = registerCustomer({ name: values.name, email: values.email, password });
if (res.ok) {
const token = signCustomer(res.id);
return new Response(null, { status: 302, headers: { 'Location': '/konto', 'Set-Cookie': buildCustomerCookie(token) } });
}
error = res.error || 'Registrierung fehlgeschlagen.';
}
---
<Base title="Konto erstellen">
<div class="wrap">
<div class="auth-card">
<h1>Konto erstellen</h1>
<p style="color:var(--faint);margin:0 0 22px">Schneller bestellen, Adressen speichern, Bestellungen verfolgen.</p>
{error && <div class="auth-error">{error}</div>}
<form method="POST">
<div class="field"><label>Name</label><input name="name" value={values.name} placeholder="Vor- und Nachname" /></div>
<div class="field"><label>E-Mail</label><input name="email" type="email" value={values.email} required /></div>
<div class="field"><label>Passwort</label><input name="password" type="password" minlength="6" required /><span style="font-size:12px;color:var(--faint)">Mindestens 6 Zeichen.</span></div>
<button class="btn btn-primary btn-lg btn-block" type="submit" style="margin-top:8px">Konto erstellen</button>
</form>
<p style="margin-top:18px;font-size:14px">Schon ein Konto? <a href="/konto/anmelden">Anmelden</a></p>
</div>
</div>
</Base>
+21
View File
@@ -0,0 +1,21 @@
---
import Base from '../layouts/Base.astro';
import { getSetting, feature } from '../lib/store.js';
// Feature-Gate: ohne Merkliste-Flag gibt es die Route nicht.
if (!feature('feature_wishlist')) return Astro.redirect('/shop');
const currency = getSetting('currency', 'EUR');
---
<Base title="Merkliste">
<section class="shop-head">
<div class="wrap">
<span class="eyebrow">Gemerkt</span>
<h1 style="margin-top:10px">Meine Merkliste</h1>
</div>
</section>
<section style="padding-bottom:64px">
<div class="wrap">
<div id="wishlistRoot" data-currency={currency}></div>
</div>
</section>
</Base>
+98 -2
View File
@@ -1,6 +1,6 @@
--- ---
import Base from '../../layouts/Base.astro'; import Base from '../../layouts/Base.astro';
import { getProductBySlug, listProducts, formatPrice, basePriceLabel } from '../../lib/store.js'; import { getProductBySlug, listProducts, formatPrice, basePriceLabel, feature, listApprovedReviews, reviewSummary } from '../../lib/store.js';
const { slug } = Astro.params; const { slug } = Astro.params;
const product = getProductBySlug(slug); const product = getProductBySlug(slug);
@@ -11,6 +11,12 @@ const gallery = product.images && product.images.length ? product.images : (prod
const addData = { slug: product.slug, name: product.name, priceCents: product.priceCents, image: product.cardImage || gallery[0] || '', sizes: product.sizes }; const addData = { slug: product.slug, name: product.name, priceCents: product.priceCents, image: product.cardImage || gallery[0] || '', sizes: product.sizes };
const mwst = (product.mwst == null ? 19 : Number(product.mwst)); const mwst = (product.mwst == null ? 19 : Number(product.mwst));
const basePrice = basePriceLabel(product.priceCents, product); const basePrice = basePriceLabel(product.priceCents, product);
const wishlistOn = feature('feature_wishlist');
const reviewsOn = feature('feature_reviews');
const reviews = reviewsOn ? listApprovedReviews(product.slug) : [];
const rsum = reviewsOn ? reviewSummary(product.slug) : { count: 0, average: 0 };
const wishData = { slug: product.slug, name: product.shortName || product.name, priceCents: product.priceCents, image: product.cardImage || gallery[0] || '', category: product.category };
function starStr(n) { const full = Math.round(n); return '★★★★★'.slice(0, full) + '☆☆☆☆☆'.slice(0, 5 - full); }
--- ---
<Base title={product.shortName || product.name} description={product.desc}> <Base title={product.shortName || product.name} description={product.desc}>
<div class="wrap"> <div class="wrap">
@@ -44,7 +50,21 @@ const basePrice = basePriceLabel(product.priceCents, product);
</> </>
)} )}
<button class="btn btn-primary btn-lg btn-block" data-add-to-cart data-product={JSON.stringify(addData)}>In den Warenkorb</button> {reviewsOn && rsum.count > 0 && (
<a href="#reviews" class="pdp-rating" style="display:inline-flex;align-items:center;gap:8px;margin:-4px 0 14px;color:var(--accent);text-decoration:none">
<span style="font-size:18px;letter-spacing:2px" aria-hidden="true">{starStr(rsum.average)}</span>
<span style="color:var(--faint);font-size:14px">{rsum.average.toFixed(1)} · {rsum.count} {rsum.count === 1 ? 'Bewertung' : 'Bewertungen'}</span>
</a>
)}
<div style="display:flex;gap:10px;align-items:stretch">
<button class="btn btn-primary btn-lg btn-block" data-add-to-cart data-product={JSON.stringify(addData)} style="flex:1">In den Warenkorb</button>
{wishlistOn && (
<button class="wish-btn wish-btn-lg" data-wish={JSON.stringify(wishData)} aria-label="Merken" title="Auf die Merkliste">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8Z"/></svg>
</button>
)}
</div>
{product.features && product.features.length > 0 && ( {product.features && product.features.length > 0 && (
<ul class="feat-list">{product.features.map((f) => (<li>{f}</li>))}</ul> <ul class="feat-list">{product.features.map((f) => (<li>{f}</li>))}</ul>
@@ -57,6 +77,46 @@ const basePrice = basePriceLabel(product.priceCents, product);
</div> </div>
</div> </div>
{reviewsOn && (
<section class="section" id="reviews">
<div class="section-head"><div><span class="eyebrow">Bewertungen</span><h2 style="margin-top:8px">Was Kund:innen sagen</h2></div></div>
{rsum.count > 0 ? (
<div class="rev-summary">
<div class="rev-avg"><span class="rev-avg-num">{rsum.average.toFixed(1)}</span><span class="rev-stars" aria-hidden="true">{starStr(rsum.average)}</span><span class="rev-count">{rsum.count} {rsum.count === 1 ? 'Bewertung' : 'Bewertungen'}</span></div>
</div>
) : (
<p style="color:var(--faint)">Noch keine Bewertungen — sei die erste Person.</p>
)}
{reviews.length > 0 && (
<div class="rev-list">
{reviews.map((r) => (
<div class="rev-item">
<div class="rev-head"><span class="rev-name">{r.name}</span><span class="rev-stars-sm" aria-label={`${r.rating} von 5 Sternen`}>{starStr(r.rating)}</span></div>
{r.text && <p class="rev-text">{r.text}</p>}
<div class="rev-date">{new Date(r.created_at).toLocaleDateString('de-DE')}</div>
</div>
))}
</div>
)}
<div class="rev-form-card">
<h3 style="margin:0 0 4px">Bewertung schreiben</h3>
<p style="color:var(--faint);font-size:14px;margin:0 0 16px">Deine Bewertung wird nach kurzer Prüfung freigeschaltet.</p>
<form id="revForm">
<input type="hidden" name="product_slug" value={product.slug} />
<div class="rev-stars-input" id="revStars" role="radiogroup" aria-label="Sterne">
{[1,2,3,4,5].map((n) => (<button type="button" class="rev-star-btn" data-val={n} aria-label={`${n} Sterne`}>★</button>))}
</div>
<input type="hidden" name="rating" id="revRating" value="5" />
<div class="field" style="margin-bottom:12px"><label>Name</label><input name="name" maxlength="80" placeholder="Dein Name" required /></div>
<div class="field" style="margin-bottom:12px"><label>Deine Bewertung</label><textarea name="text" rows="4" maxlength="2000" placeholder="Wie gefällt dir das Produkt?"></textarea></div>
<button type="submit" class="btn btn-primary">Bewertung absenden</button>
<div id="revMsg" style="margin-top:10px;font-size:14px"></div>
</form>
</div>
</section>
)}
{related.length > 0 && ( {related.length > 0 && (
<section class="section"> <section class="section">
<div class="section-head"><div><span class="eyebrow">Passt dazu</span><h2 style="margin-top:8px">Ähnliche Produkte</h2></div></div> <div class="section-head"><div><span class="eyebrow">Passt dazu</span><h2 style="margin-top:8px">Ähnliche Produkte</h2></div></div>
@@ -80,6 +140,14 @@ const basePrice = basePriceLabel(product.priceCents, product);
)} )}
</div> </div>
{reviewsOn && rsum.count > 0 && (
<script type="application/ld+json" is:inline set:html={JSON.stringify({
'@context': 'https://schema.org', '@type': 'Product', name: product.name,
description: product.desc || undefined,
aggregateRating: { '@type': 'AggregateRating', ratingValue: rsum.average, reviewCount: rsum.count, bestRating: 5, worstRating: 1 },
})}></script>
)}
<script is:inline> <script is:inline>
(function () { (function () {
var main = document.getElementById('pdpMain'); var main = document.getElementById('pdpMain');
@@ -90,6 +158,34 @@ const basePrice = basePriceLabel(product.priceCents, product);
t.classList.add('active'); t.classList.add('active');
}); });
}); });
// Bewertungs-Formular
var form = document.getElementById('revForm');
if (form) {
var rating = 5;
var starBtns = Array.prototype.slice.call(document.querySelectorAll('.rev-star-btn'));
function paint(v) { starBtns.forEach(function (b, i) { b.classList.toggle('on', i < v); }); }
starBtns.forEach(function (b) {
b.addEventListener('click', function () { rating = +b.getAttribute('data-val'); document.getElementById('revRating').value = rating; paint(rating); });
b.addEventListener('mouseenter', function () { paint(+b.getAttribute('data-val')); });
});
var starsWrap = document.getElementById('revStars');
if (starsWrap) starsWrap.addEventListener('mouseleave', function () { paint(rating); });
paint(rating);
form.addEventListener('submit', function (e) {
e.preventDefault();
var msg = document.getElementById('revMsg');
var fd = new FormData(form), body = {}; fd.forEach(function (v, k) { body[k] = v; });
msg.style.color = 'var(--faint)'; msg.textContent = 'Wird gesendet …';
fetch('/api/review', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.ok) { form.reset(); rating = 5; document.getElementById('revRating').value = 5; paint(5); msg.style.color = 'var(--accent)'; msg.textContent = 'Danke! Deine Bewertung wird nach Prüfung veröffentlicht.'; }
else { msg.style.color = '#b3261e'; msg.textContent = d.error || 'Bitte Eingaben prüfen.'; }
})
.catch(function () { msg.style.color = '#b3261e'; msg.textContent = 'Bitte später erneut versuchen.'; });
});
}
})(); })();
</script> </script>
</Base> </Base>
+22 -14
View File
@@ -1,12 +1,13 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import { listProducts, listCategories, formatPrice, basePriceLabel } from '../lib/store.js'; import { listProducts, listCategories, formatPrice, basePriceLabel, feature } from '../lib/store.js';
const products = listProducts(); const products = listProducts();
const categories = listCategories(); const categories = listCategories();
const url = new URL(Astro.request.url); const url = new URL(Astro.request.url);
const activeCat = url.searchParams.get('cat') || ''; const activeCat = url.searchParams.get('cat') || '';
const filtered = activeCat ? products.filter(p => p.category === activeCat) : products; const filtered = activeCat ? products.filter(p => p.category === activeCat) : products;
const wishlistOn = feature('feature_wishlist');
--- ---
<Base title="Shop"> <Base title="Shop">
<section class="shop-head"> <section class="shop-head">
@@ -29,19 +30,26 @@ const filtered = activeCat ? products.filter(p => p.category === activeCat) : pr
) : ( ) : (
<div class="prod-grid"> <div class="prod-grid">
{filtered.map((p) => ( {filtered.map((p) => (
<a class="prod-card" href={`/produkt/${p.slug}`}> <div class="prod-card-wrap">
<div class="prod-media"> <a class="prod-card" href={`/produkt/${p.slug}`}>
{p.cardImage && <img src={p.cardImage} alt={p.name} loading="lazy" />} <div class="prod-media">
{p.badge && <span class="prod-badge">{p.badge}</span>} {p.cardImage && <img src={p.cardImage} alt={p.name} loading="lazy" />}
</div> {p.badge && <span class="prod-badge">{p.badge}</span>}
<div class="prod-info"> </div>
<span class="prod-cat">{p.category}</span> <div class="prod-info">
<span class="prod-name">{p.shortName || p.name}</span> <span class="prod-cat">{p.category}</span>
{p.stock === 0 && <span class="soldout">Ausverkauft</span>} <span class="prod-name">{p.shortName || p.name}</span>
<span class="prod-price">{formatPrice(p.priceCents)}</span> {p.stock === 0 && <span class="soldout">Ausverkauft</span>}
<span class="prod-tax">inkl. MwSt.{basePriceLabel(p.priceCents, p) && ' · ' + basePriceLabel(p.priceCents, p)}</span> <span class="prod-price">{formatPrice(p.priceCents)}</span>
</div> <span class="prod-tax">inkl. MwSt.{basePriceLabel(p.priceCents, p) && ' · ' + basePriceLabel(p.priceCents, p)}</span>
</a> </div>
</a>
{wishlistOn && (
<button class="wish-btn" data-wish={JSON.stringify({ slug: p.slug, name: p.shortName || p.name, priceCents: p.priceCents, image: p.cardImage, category: p.category })} aria-label="Merken" title="Merken">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8Z"/></svg>
</button>
)}
</div>
))} ))}
</div> </div>
)} )}
+67
View File
@@ -0,0 +1,67 @@
---
import Base from '../layouts/Base.astro';
import { searchProducts, formatPrice, basePriceLabel, feature, recordEvent } from '../lib/store.js';
// Feature-Gate: ist die Suche aus, gibt es die Route nicht.
if (!feature('feature_search')) return Astro.redirect('/shop');
const url = new URL(Astro.request.url);
const q = (url.searchParams.get('q') || '').trim();
const wishlistOn = feature('feature_wishlist');
const results = q ? searchProducts(q) : [];
if (q) {
try { recordEvent({ type: 'product_view', path: '/suche', meta: { search: q, hits: results.length } }); } catch {}
}
---
<Base title={q ? `Suche: ${q}` : 'Suche'}>
<section class="shop-head">
<div class="wrap">
<span class="eyebrow">Suche</span>
<h1 style="margin-top:10px">{q ? `Ergebnisse für „${q}"` : 'Produktsuche'}</h1>
<form method="GET" action="/suche" class="search-page-form" role="search">
<input type="search" name="q" value={q} placeholder="Wonach suchst du?" aria-label="Suchbegriff" autofocus />
<button type="submit" class="btn btn-primary">Suchen</button>
</form>
{q && <p style="margin-top:12px;color:var(--faint)">{results.length} {results.length === 1 ? 'Treffer' : 'Treffer'}</p>}
</div>
</section>
<section style="padding-bottom:64px">
<div class="wrap">
{q && results.length === 0 ? (
<div class="empty-state">
<h2>Keine Treffer</h2>
<p>Für „{q}" haben wir nichts gefunden. Versuch es mit einem anderen Begriff oder stöbere im Shop.</p>
<a class="btn btn-primary btn-lg" href="/shop" style="margin-top:16px">Zum Shop</a>
</div>
) : !q ? (
<div class="empty-state"><h2>Gib einen Suchbegriff ein</h2><p>Durchsuche unser Sortiment nach Name, Kategorie oder Beschreibung.</p></div>
) : (
<div class="prod-grid">
{results.map((p) => (
<div class="prod-card-wrap">
<a class="prod-card" href={`/produkt/${p.slug}`}>
<div class="prod-media">
{p.cardImage && <img src={p.cardImage} alt={p.name} loading="lazy" />}
{p.badge && <span class="prod-badge">{p.badge}</span>}
</div>
<div class="prod-info">
<span class="prod-cat">{p.category}</span>
<span class="prod-name">{p.shortName || p.name}</span>
{p.stock === 0 && <span class="soldout">Ausverkauft</span>}
<span class="prod-price">{formatPrice(p.priceCents)}</span>
<span class="prod-tax">inkl. MwSt.{basePriceLabel(p.priceCents, p) && ' · ' + basePriceLabel(p.priceCents, p)}</span>
</div>
</a>
{wishlistOn && (
<button class="wish-btn" data-wish={JSON.stringify({ slug: p.slug, name: p.shortName || p.name, priceCents: p.priceCents, image: p.cardImage, category: p.category })} aria-label="Merken" title="Merken">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8Z"/></svg>
</button>
)}
</div>
))}
</div>
)}
</div>
</section>
</Base>
+3
View File
@@ -238,3 +238,6 @@
.s-feed-row .t{color:var(--s-faint);margin-left:auto;font-size:12px;white-space:nowrap} .s-feed-row .t{color:var(--s-faint);margin-left:auto;font-size:12px;white-space:nowrap}
@keyframes fade{from{opacity:0}to{opacity:1}} @keyframes fade{from{opacity:0}to{opacity:1}}
@keyframes pop{from{opacity:0;transform:translateY(-6px) scale(.98)}to{opacity:1;transform:none}} @keyframes pop{from{opacity:0;transform:translateY(-6px) scale(.98)}to{opacity:1;transform:none}}
/* v2.3 — Bewertungs-Zähler in der Nav */
.s-nav-badge{margin-left:auto;min-width:18px;height:18px;padding:0 5px;border-radius:999px;background:var(--accent);color:#fff;font-size:11px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;line-height:1}
+75
View File
@@ -262,3 +262,78 @@ p{margin:0 0 1rem}
.blk-cta-box .btn-primary:hover{background:rgba(255,255,255,.9)} .blk-cta-box .btn-primary:hover{background:rgba(255,255,255,.9)}
.blk-html{padding:8px 0} .blk-html{padding:8px 0}
@media(max-width:760px){.blk-feat-grid{grid-template-columns:1fr}.blk-gal-grid{grid-template-columns:repeat(2,1fr)!important}} @media(max-width:760px){.blk-feat-grid{grid-template-columns:1fr}.blk-gal-grid{grid-template-columns:repeat(2,1fr)!important}}
/* ============ v2.3 Feature-Module: Suche, Merkliste, Konto, Bewertungen ============ */
/* Header-Suche */
.head-search{display:flex;align-items:center;gap:8px;background:var(--sunken);border:1px solid var(--border);border-radius:999px;padding:7px 14px;transition:.15s;max-width:220px}
.head-search:focus-within{border-color:var(--accent);background:var(--surface)}
.head-search svg{width:18px;height:18px;color:var(--faint);flex-shrink:0}
.head-search input{border:none;background:transparent;outline:none;font-size:14px;color:var(--ink);width:100%;min-width:0}
.search-page-form{display:flex;gap:10px;margin-top:18px;max-width:540px}
.search-page-form input{flex:1;padding:13px 16px;border:1px solid var(--border-2);border-radius:var(--radius-sm);font-size:15px;background:var(--surface)}
.search-page-form input:focus{outline:none;border-color:var(--accent)}
@media(max-width:760px){.head-search{display:none}}
/* Merkliste / Wunschliste */
.wish-badge{position:absolute;top:4px;right:4px;min-width:18px;height:18px;padding:0 4px;border-radius:999px;background:var(--accent);color:#fff;font-size:11px;font-weight:700;display:none;align-items:center;justify-content:center;line-height:1}
.wish-badge.show{display:flex}
.prod-card-wrap{position:relative;display:flex}
.prod-card-wrap .prod-card{flex:1}
.wish-btn{position:absolute;top:12px;right:12px;z-index:3;width:38px;height:38px;border-radius:999px;border:1px solid var(--border);background:rgba(255,255,255,.92);color:var(--subtle);display:grid;place-items:center;cursor:pointer;transition:.15s;backdrop-filter:blur(4px)}
.wish-btn:hover{color:var(--accent);border-color:var(--accent);transform:scale(1.06)}
.wish-btn svg{width:19px;height:19px;fill:none}
.wish-btn.active{color:var(--accent);border-color:var(--accent)}
.wish-btn.active svg{fill:var(--accent)}
.wish-btn-lg{position:static;width:54px;height:auto;border-radius:var(--radius-sm);flex-shrink:0}
.wish-btn-lg svg{width:22px;height:22px}
/* Konto / Auth */
.auth-card{max-width:440px;margin:56px auto;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:36px;box-shadow:var(--shadow)}
.auth-card h1{margin-bottom:6px}
.auth-error{background:color-mix(in srgb,#b3261e 10%,white);color:#b3261e;border:1px solid color-mix(in srgb,#b3261e 30%,white);padding:11px 14px;border-radius:var(--radius-sm);font-size:14px;margin-bottom:18px}
.auth-flash{background:color-mix(in srgb,var(--accent) 12%,white);color:var(--accent-dark);border:1px solid color-mix(in srgb,var(--accent) 28%,white);padding:11px 14px;border-radius:var(--radius-sm);font-size:14px;margin:18px 0}
.konto-head{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;flex-wrap:wrap;padding:44px 0 8px;border-bottom:1px solid var(--border);margin-bottom:8px}
.konto-orders{display:flex;flex-direction:column;gap:12px}
.konto-order{display:grid;grid-template-columns:auto 1fr auto;grid-template-areas:"num status total" "date items total";gap:4px 16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:16px 20px;align-items:center}
.ko-top{grid-area:num;display:flex;gap:10px;align-items:center}
.ko-num{font-weight:700;color:var(--ink)}
.ko-status{font-size:12px;font-weight:700;padding:3px 9px;border-radius:999px;background:var(--sunken);color:var(--subtle)}
.ko-status.ko-paid,.ko-status.ko-fulfilled{background:color-mix(in srgb,#2e7d32 14%,white);color:#2e7d32}
.ko-status.ko-pending{background:color-mix(in srgb,#b8860b 16%,white);color:#8a6d00}
.ko-date{grid-area:date;font-size:13px;color:var(--faint)}
.ko-items{grid-area:items;font-size:14px;color:var(--subtle)}
.ko-total{grid-area:total;font-weight:700;font-size:17px;color:var(--ink);white-space:nowrap}
.konto-addr-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px;margin-bottom:20px}
.konto-addr{position:relative;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:18px 20px}
.konto-addr.is-default{border-color:var(--accent)}
.addr-default-badge{position:absolute;top:14px;right:14px;font-size:11px;font-weight:700;color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,white);padding:3px 9px;border-radius:999px}
.addr-name{font-weight:700;color:var(--ink);margin-bottom:6px}
.addr-lines{font-size:14px;color:var(--subtle);line-height:1.55}
.addr-actions{margin-top:14px;display:flex;gap:14px}
.addr-link{background:none;border:none;padding:0;font-size:13px;color:var(--accent);cursor:pointer;font-weight:600}
.addr-link.danger{color:#b3261e}
.addr-add{margin-top:8px}
.addr-add summary{cursor:pointer;font-weight:600;color:var(--accent);display:inline-block;padding:8px 0}
.addr-form{margin-top:14px;max-width:520px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:22px}
/* Bewertungen (PDP) */
.pdp-rating{font-size:15px}
.rev-summary{margin-bottom:24px}
.rev-avg{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.rev-avg-num{font-family:var(--serif);font-size:42px;color:var(--ink);line-height:1}
.rev-stars{color:var(--accent);font-size:22px;letter-spacing:2px}
.rev-count{color:var(--faint);font-size:14px}
.rev-list{display:flex;flex-direction:column;gap:16px;margin-bottom:32px}
.rev-item{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);padding:18px 20px}
.rev-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:6px}
.rev-name{font-weight:700;color:var(--ink)}
.rev-stars-sm{color:var(--accent);letter-spacing:1px}
.rev-text{color:var(--text);margin:6px 0;line-height:1.6}
.rev-date{font-size:12px;color:var(--faint)}
.rev-form-card{background:var(--sunken);border:1px solid var(--border);border-radius:var(--radius);padding:26px;max-width:560px}
.rev-stars-input{display:flex;gap:4px;margin-bottom:16px}
.rev-star-btn{background:none;border:none;font-size:32px;line-height:1;color:var(--border-2);cursor:pointer;padding:0;transition:.1s}
.rev-star-btn.on{color:var(--accent)}
.rev-form-card .field label{font-weight:600;font-size:14px;color:var(--ink)}
.rev-form-card input,.rev-form-card textarea{padding:11px 13px;border:1px solid var(--border-2);border-radius:var(--radius-sm);font-size:15px;font-family:inherit;background:var(--surface)}
.rev-form-card input:focus,.rev-form-card textarea:focus{outline:none;border-color:var(--accent)}
+42
View File
@@ -29,5 +29,47 @@ t('Sanitizer entfernt onerror=', () => assert.ok(!/onerror/i.test(sanitizeHtml('
t('Sanitizer neutralisiert javascript:', () => assert.ok(!/javascript:/i.test(sanitizeHtml('<a href="javascript:alert(1)">x</a>')))); t('Sanitizer neutralisiert javascript:', () => assert.ok(!/javascript:/i.test(sanitizeHtml('<a href="javascript:alert(1)">x</a>'))));
t('Sanitizer lässt normales Markup', () => assert.ok(/<strong>/.test(sanitizeHtml('<strong>fett</strong>')))); t('Sanitizer lässt normales Markup', () => assert.ok(/<strong>/.test(sanitizeHtml('<strong>fett</strong>'))));
// --- v2.3 Feature-Module ---
// Suche: Treffer + leerer Query
{
const id = store.createProduct({ name: 'Blaues Wollknäuel Merino', shortName: 'Wollknäuel', priceCents: 990, category: 'Garne', desc: 'Weiche Merinowolle in Blau', mwst: 19 });
t('Suche findet Produkt nach Name (woll)', () => { const r = store.searchProducts('woll'); assert.ok(r.some(p => p.id === id)); });
t('Suche case-insensitiv (MERINO)', () => assert.ok(store.searchProducts('MERINO').some(p => p.id === id)));
t('Suche findet nach Kategorie (garne)', () => assert.ok(store.searchProducts('garne').some(p => p.id === id)));
t('Suche leerer Query → keine Treffer', () => assert.strictEqual(store.searchProducts('').length, 0));
t('Suche ohne Treffer → leeres Array', () => assert.strictEqual(store.searchProducts('xyzgibtsnicht123').length, 0));
}
// Bewertungen: Durchschnitt nur über freigegebene
{
const slug = 'test-review-produkt';
const r1 = store.addReview({ product_slug: slug, name: 'A', rating: 5, text: 'top' });
const r2 = store.addReview({ product_slug: slug, name: 'B', rating: 3, text: 'ok' });
store.addReview({ product_slug: slug, name: 'C', rating: 1, text: 'nicht freigegeben' });
store.setReviewApproved(r1.id, 1);
store.setReviewApproved(r2.id, 1);
t('Review-Average = 4.0 (5+3)/2, nur freigegebene', () => { const s = store.reviewSummary(slug); assert.strictEqual(s.count, 2); assert.strictEqual(s.average, 4); });
t('Review-Rating ausserhalb 15 → Fehler', () => assert.ok(!store.addReview({ product_slug: slug, name: 'D', rating: 9 }).ok));
t('addReview ohne Rating → Fehler', () => assert.ok(!store.addReview({ product_slug: slug, rating: 0 }).ok));
}
// Kundenkonten: Registrierung + Passwort-Prüfung (getrennte Auth)
{
const reg = store.registerCustomer({ name: 'Test Kunde', email: 'kunde@example.com', password: 'geheim123' });
t('Kunde registrieren', () => assert.ok(reg.ok && reg.id));
t('Kunde Login korrekt', () => assert.ok(store.verifyCustomer('kunde@example.com', 'geheim123')));
t('Kunde Login falsches Passwort → null', () => assert.strictEqual(store.verifyCustomer('kunde@example.com', 'falsch'), null));
t('Kurzes Passwort → Fehler', () => assert.ok(!store.registerCustomer({ email: 'x@y.de', password: '123' }).ok));
}
// Abandoned-Cart: erfassen, fällig, recovered
{
const cap = store.captureAbandonedCart({ email: 'cart@example.com', items: [{ name: 'X', priceCents: 1000, qty: 2 }], total_cents: 2000 });
t('Abandoned-Cart erfassen', () => assert.ok(cap.ok && cap.id));
t('Abandoned-Cart Stats zählt offen', () => assert.ok(store.abandonedCartStats().open >= 1));
t('recovered markieren per E-Mail', () => { const n = store.markCartRecoveredByEmail('cart@example.com'); assert.ok(n >= 1); });
t('recovered Karte nicht mehr fällig', () => { const due = store.dueAbandonedCarts(0); assert.ok(!due.some(c => c.email === 'cart@example.com')); });
}
console.log(`\n${pass} passed, ${fail} failed`); console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0); process.exit(fail ? 1 : 0);