v2.2: Verkaufsfertig-Fundament — Mollie/Payment-Abstraktion, MwSt/Grundpreis (PAngV), Versandzonen, Bestellmails (Listmonk/SMTP/Log), Feature-Flags

- payments.js: einheitliche createPayment/Webhook-Schnittstelle (Mollie Default, Stripe, Demo); Auto-Provider-Wahl; Mollie-REST + /api/payments/webhook (idempotent); Fake-Key => sauberer Demo-Fallback
- mailer.js: sendMail via Listmonk-Tx / SMTP (nodemailer) / Log-Fallback (email_log); gebrandete Bestellbestaetigung bei paid
- DACH: products.mwst + base_amount/base_unit/base_price_per (Grundpreis); Storefront/Warenkorb/Checkout/Erfolg/Admin mit MwSt-Ausweis + Versand-Transparenz; tax_cents/shipping_cents/country an Orders
- shipping_zones-Tabelle + CRUD + shippingFor(); Admin 'Versand'; serverseitige Versandberechnung in /api/checkout + /api/shipping-quote (Laenderwahl live)
- Feature-Flags (feature_*) + feature()-Helper; Admin Module-Toggles; Newsletter-Gating (Popup/Subscribe)
- Admin-API/Manifest/ai-admin.txt um shipping_zones erweitert; MCP list/upsert/delete_shipping; README/.env.example ergaenzt; Version 2.2.0
This commit is contained in:
2026-06-17 16:37:10 +00:00
parent 430fa718fa
commit e5514dd5da
31 changed files with 1077 additions and 129 deletions
+24
View File
@@ -25,3 +25,27 @@ STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
# --- MCP-Server (mcp/) --- # --- MCP-Server (mcp/) ---
# HDC_BASE_URL=https://shop.example.com # HDC_BASE_URL=https://shop.example.com
# HDC_API_TOKEN= (dasselbe Token wie oben) # HDC_API_TOKEN= (dasselbe Token wie oben)
# --- Zahlung: Provider-Abstraktion (v2.2) ---
# Provider explizit wählen: mollie | stripe | demo. Leer => Auto-Wahl nach vorhandenen Keys.
PAYMENT_PROVIDER=
# Mollie (Default-Anbieter). Test- oder Live-Key. Ohne gültigen Key läuft der Demo-Fallback.
MOLLIE_API_KEY=
# (Stripe-Keys siehe oben — werden weiterhin unterstützt.)
# --- E-Mail-Versand (v2.2) ---
# Provider: listmonk | smtp | (leer => Log-Fallback in DB-Tabelle email_log)
MAIL_PROVIDER=
# Absenderadresse für ausgehende Mails
MAIL_FROM=shop@example.com
# Listmonk (Transactional-API /api/tx)
LISTMONK_URL=https://listmonk.example.com
LISTMONK_USER=
LISTMONK_PASS=
LISTMONK_TX_TEMPLATE_ID=
# SMTP (alternativ, via nodemailer)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_SECURE=false
+13 -1
View File
@@ -11,6 +11,12 @@ Die mitgelieferte Demo-Instanz heißt **„Brittas Nähkiste"** (Kurzwaren/Nähb
- **Visual-Block-Builder**: Vollbild-Editor mit Block-Liste (Drag/▲▼/duplizieren/löschen), Live-Vorschau (Desktop/Mobil) und Block-Einstellungen. Block-Typen: Hero, Rich-Text, Bild, Galerie, Slider, Feature-Grid, Produkt-Grid, CTA-Banner, Abstand, Roh-HTML. - **Visual-Block-Builder**: Vollbild-Editor mit Block-Liste (Drag/▲▼/duplizieren/löschen), Live-Vorschau (Desktop/Mobil) und Block-Einstellungen. Block-Typen: Hero, Rich-Text, Bild, Galerie, Slider, Feature-Grid, Produkt-Grid, CTA-Banner, Abstand, Roh-HTML.
- **KI-Editierbarkeit**: token-gesicherte Admin-JSON-API (`/api/admin/*`) plus maschinenlesbares Manifest (`/api/admin`, `/ai-admin.txt`) und ein **MCP-Server** (`mcp/`). - **KI-Editierbarkeit**: token-gesicherte Admin-JSON-API (`/api/admin/*`) plus maschinenlesbares Manifest (`/api/admin`, `/ai-admin.txt`) und ein **MCP-Server** (`mcp/`).
- **Gutschein-/Rabatt-Engine** (v2.1): Codes vom Typ `percent` / `fixed` / `freeshipping` mit Zeitplan, Mindestbestellwert, Gesamt- und Pro-Kunde-Limit, „geheim" (nicht öffentlich listbar) und „automatisch" (greift ohne Code, wenn Bedingungen erfüllt). Admin-Bereich **Rabatte** (Owner/Redaktion) mit Status-Badges (Aktiv/Geplant/Abgelaufen/Aufgebraucht/Inaktiv); Storefront-Einlösung im Checkout über `/api/discount`; serverseitige Re-Validierung in `/api/checkout`; Stripe-Coupon-Anbindung. Popups können einen Code anzeigen (+ Kopieren-Button) — auch für gezielt verteilte geheime Codes; Popup-Stile `modal` / `slidein` / `bar`. - **Gutschein-/Rabatt-Engine** (v2.1): Codes vom Typ `percent` / `fixed` / `freeshipping` mit Zeitplan, Mindestbestellwert, Gesamt- und Pro-Kunde-Limit, „geheim" (nicht öffentlich listbar) und „automatisch" (greift ohne Code, wenn Bedingungen erfüllt). Admin-Bereich **Rabatte** (Owner/Redaktion) mit Status-Badges (Aktiv/Geplant/Abgelaufen/Aufgebraucht/Inaktiv); Storefront-Einlösung im Checkout über `/api/discount`; serverseitige Re-Validierung in `/api/checkout`; Stripe-Coupon-Anbindung. Popups können einen Code anzeigen (+ Kopieren-Button) — auch für gezielt verteilte geheime Codes; Popup-Stile `modal` / `slidein` / `bar`.
- **Verkaufsfertig-Fundament (v2.2):**
- **Payment-Abstraktion** (`src/lib/payments.js`): einheitliche Schnittstelle für **Mollie** (Default, REST), **Stripe** und **Demo**. Provider via Setting `payment_provider` / ENV `PAYMENT_PROVIDER`, sonst Auto-Wahl nach vorhandenen Keys. Mollie-Webhook unter `/api/payments/webhook`; ungültiger Key ⇒ sauberer Demo-Fallback statt Fehler.
- **DACH-Recht**: MwSt-Ausweis pro Produkt (`mwst` 0/7/19), **Grundpreis** (PAngV) über `base_amount`/`base_unit`/`base_price_per`; Warenkorb/Checkout zeigen Zwischensumme, enthaltene MwSt (nach Satz gruppiert) und **Versand vor dem Bezahlen**.
- **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.
- **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)`.
- **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).
@@ -37,6 +43,12 @@ Die mitgelieferte Demo-Instanz heißt **„Brittas Nähkiste"** (Kurzwaren/Nähb
| `HDC_API_TOKEN` | Bearer-Token für `/api/admin/*`. Leer ⇒ API gesperrt | | | `HDC_API_TOKEN` | Bearer-Token für `/api/admin/*`. Leer ⇒ API gesperrt | |
| `STRIPE_PUBLIC_KEY` | Stripe Publishable Key (optional) | | | `STRIPE_PUBLIC_KEY` | Stripe Publishable Key (optional) | |
| `STRIPE_SECRET_KEY` | Stripe Secret Key. Ohne echten Key läuft der Demo-Checkout. | | | `STRIPE_SECRET_KEY` | Stripe Secret Key. Ohne echten Key läuft der Demo-Checkout. | |
| `PAYMENT_PROVIDER` | Zahlungsanbieter erzwingen: `mollie` / `stripe` / `demo`. Leer ⇒ Auto-Wahl | |
| `MOLLIE_API_KEY` | Mollie API-Key (`test_…`/`live_…`). Ohne gültigen Key Demo-Fallback | |
| `MAIL_PROVIDER` | `listmonk` / `smtp` / leer (⇒ Log-Fallback in `email_log`) | |
| `MAIL_FROM` | Absenderadresse ausgehender Mails | |
| `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) | |
Siehe `.env.example`. Siehe `.env.example`.
@@ -92,7 +104,7 @@ Das `Dockerfile` (node:22-slim) baut `better-sqlite3` nativ, legt `/data` an und
## Datenmodell ## Datenmodell
`settings`, `products`, `orders` (inkl. `discount_code` / `discount_cents`), `customers`, `slides`, `pages` (inkl. `blocks`; System-Seite `404`), `popups` (inkl. `style` / `discount_id`), `discounts`, `discount_redemptions`, `subscribers`, `events`, `media`, `users`, `audit` — alles seed-bar und im Admin pflegbar. `settings` (inkl. Feature-Flags & `payment_provider`), `products` (inkl. `mwst` / `base_amount` / `base_unit` / `base_price_per`), `orders` (inkl. `discount_code`/`discount_cents`, `tax_cents`/`shipping_cents`/`country`, `payment_provider`/`payment_id`), `customers`, `slides`, `pages` (inkl. `blocks`; System-Seite `404`), `popups` (inkl. `style` / `discount_id`), `discounts`, `discount_redemptions`, `shipping_zones`, `email_log`, `subscribers`, `events`, `media`, `users`, `audit` — alles seed-bar und im Admin pflegbar.
--- ---
+7 -1
View File
@@ -36,12 +36,15 @@ const TOOLS = [
{ name: 'list_discounts', description: 'Alle Rabatte/Gutscheine auflisten.', inputSchema: { type: 'object', properties: {} } }, { name: 'list_discounts', description: 'Alle Rabatte/Gutscheine auflisten.', inputSchema: { type: 'object', properties: {} } },
{ name: 'upsert_discount', description: 'Rabatt anlegen/aktualisieren. Mit id oder code => Update, sonst Create. type: percent|fixed|freeshipping; value bei percent 1-100, bei fixed in Cent.', inputSchema: { type: 'object', properties: { discount: { type: 'object', description: 'Felder: code, title, type, value, min_order_cents, starts_at, expires_at, max_uses, max_per_customer, active, secret, auto' } }, required: ['discount'] } }, { name: 'upsert_discount', description: 'Rabatt anlegen/aktualisieren. Mit id oder code => Update, sonst Create. type: percent|fixed|freeshipping; value bei percent 1-100, bei fixed in Cent.', inputSchema: { type: 'object', properties: { discount: { type: 'object', description: 'Felder: code, title, type, value, min_order_cents, starts_at, expires_at, max_uses, max_per_customer, active, secret, auto' } }, required: ['discount'] } },
{ name: 'delete_discount', description: 'Rabatt löschen (per ID).', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } }, { name: 'delete_discount', description: 'Rabatt löschen (per ID).', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
{ name: 'list_shipping', description: 'Versandzonen auflisten.', inputSchema: { type: 'object', properties: {} } },
{ name: 'upsert_shipping', description: 'Versandzone anlegen/aktualisieren (mit id => Update). Felder: name, countries (CSV ISO / EU), price_cents, free_over_cents, delivery_days, sort, active.', inputSchema: { type: 'object', properties: { zone: { type: 'object' } }, required: ['zone'] } },
{ 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: '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.1.0' }, { capabilities: { tools: {} } }); const server = new Server({ name: 'hd-commerce', version: '2.2.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) => {
@@ -63,6 +66,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
case 'list_discounts': out = await api('GET', '/api/admin/discounts'); break; case 'list_discounts': out = await api('GET', '/api/admin/discounts'); break;
case 'upsert_discount': out = await api('POST', '/api/admin/discounts', a.discount); break; case 'upsert_discount': out = await api('POST', '/api/admin/discounts', a.discount); break;
case 'delete_discount': out = await api('DELETE', '/api/admin/discounts/' + encodeURIComponent(a.id)); break; case 'delete_discount': out = await api('DELETE', '/api/admin/discounts/' + encodeURIComponent(a.id)); break;
case 'list_shipping': out = await api('GET', '/api/admin/shipping_zones'); break;
case 'upsert_shipping': out = await api('POST', '/api/admin/shipping_zones', a.zone); 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 'get_manifest': out = await api('GET', '/api/admin'); break; case 'get_manifest': out = await api('GET', '/api/admin'); break;
+10
View File
@@ -13,6 +13,7 @@
"@fontsource-variable/public-sans": "^5.1.0", "@fontsource-variable/public-sans": "^5.1.0",
"astro": "^5.6.0", "astro": "^5.6.0",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.8.1",
"nodemailer": "^6.10.1",
"stripe": "^17.5.0" "stripe": "^17.5.0"
} }
}, },
@@ -4178,6 +4179,15 @@
"integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+6 -5
View File
@@ -1,7 +1,7 @@
{ {
"name": "hd-commerce", "name": "hd-commerce",
"type": "module", "type": "module",
"version": "2.1.0", "version": "2.2.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": {
@@ -11,11 +11,12 @@
"prebuild": "node ./scripts/sync-css.mjs" "prebuild": "node ./scripts/sync-css.mjs"
}, },
"dependencies": { "dependencies": {
"astro": "^5.6.0",
"@astrojs/node": "^9.1.3", "@astrojs/node": "^9.1.3",
"better-sqlite3": "^11.8.1",
"stripe": "^17.5.0",
"@fontsource-variable/fraunces": "^5.1.0", "@fontsource-variable/fraunces": "^5.1.0",
"@fontsource-variable/public-sans": "^5.1.0" "@fontsource-variable/public-sans": "^5.1.0",
"astro": "^5.6.0",
"better-sqlite3": "^11.8.1",
"nodemailer": "^6.10.1",
"stripe": "^17.5.0"
} }
} }
+2 -2
View File
@@ -1,5 +1,5 @@
--- ---
import { listFeatured, listProducts, listActiveSlides, formatPrice } from '../lib/store.js'; import { listFeatured, listProducts, listActiveSlides, formatPrice, basePriceLabel } from '../lib/store.js';
export interface Props { blocks?: any[] } export interface Props { blocks?: any[] }
const { blocks = [] } = Astro.props; const { blocks = [] } = Astro.props;
@@ -84,7 +84,7 @@ const galCols = (b) => Math.max(2, Math.min(4, Number(b.columns) || 3));
{productsFor(b).map((p) => ( {productsFor(b).map((p) => (
<a class="prod-card" href={`/produkt/${p.slug}`}> <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-media">{p.cardImage && <img src={p.cardImage} alt={p.name} loading="lazy" />}{p.badge && <span class="prod-badge">{p.badge}</span>}</div>
<div class="prod-info"><span class="prod-cat">{p.category}</span><span class="prod-name">{p.shortName || p.name}</span><span class="prod-price">{formatPrice(p.priceCents)}</span></div> <div class="prod-info"><span class="prod-cat">{p.category}</span><span class="prod-name">{p.shortName || p.name}</span><span class="prod-price">{formatPrice(p.priceCents)}</span><span class="prod-tax">inkl. MwSt.{basePriceLabel(p.priceCents, p) && ' · ' + basePriceLabel(p.priceCents, p)}</span></div>
</a> </a>
))} ))}
</div> </div>
+1
View File
@@ -30,6 +30,7 @@ const allNav = [
{ key:'marketing', label:'Marketing', href: base + '/marketing', icon:'M3 11l18-5v12l-7-2v4l-4-1v-3L3 11Z' }, { key:'marketing', label:'Marketing', href: base + '/marketing', icon:'M3 11l18-5v12l-7-2v4l-4-1v-3L3 11Z' },
{ key:'rabatte', label:'Rabatte', href: base + '/rabatte', icon:'M9 9h.01M15 15h.01M8 21l13-13a2.83 2.83 0 0 0 0-4 2.83 2.83 0 0 0-4 0L4 17a2 2 0 0 0 0 3 2 2 0 0 0 4 1Z' }, { key:'rabatte', label:'Rabatte', href: base + '/rabatte', icon:'M9 9h.01M15 15h.01M8 21l13-13a2.83 2.83 0 0 0 0-4 2.83 2.83 0 0 0-4 0L4 17a2 2 0 0 0 0 3 2 2 0 0 0 4 1Z' },
{ 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' },
]; ];
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' },
+5 -2
View File
@@ -2,7 +2,7 @@
import '@fontsource-variable/fraunces'; import '@fontsource-variable/fraunces';
import '@fontsource-variable/public-sans'; import '@fontsource-variable/public-sans';
import '../styles/global.css'; import '../styles/global.css';
import { getSettings, listLegalPages, listCategories, popupsForPath } from '../lib/store.js'; import { getSettings, listLegalPages, listCategories, popupsForPath, feature } from '../lib/store.js';
export interface Props { title?: string; description?: string; } export interface Props { title?: string; description?: string; }
const { title, description } = Astro.props; const { title, description } = Astro.props;
@@ -22,7 +22,10 @@ const pageTitle = title ? `${title} · ${shopName}` : shopName;
const desc = description || tagline || `${shopName} — Online-Shop`; const desc = description || tagline || `${shopName} — Online-Shop`;
const path = new URL(Astro.request.url).pathname; const path = new URL(Astro.request.url).pathname;
const popups = popupsForPath(path); let popups = popupsForPath(path);
// Feature-Flag: Newsletter-Popups nur zeigen, wenn Newsletter aktiv ist.
const newsletterOn = feature('feature_newsletter');
if (!newsletterOn) popups = popups.filter((pp) => pp.type !== 'newsletter');
--- ---
<!doctype html> <!doctype html>
<html lang="de"> <html lang="de">
+14 -3
View File
@@ -16,13 +16,14 @@ export function authOk(request) {
// ---- Ressourcen-Definitionen für das Manifest ---- // ---- Ressourcen-Definitionen für das Manifest ----
export const RESOURCES = { export const RESOURCES = {
products: { rw: true, fields: ['slug', 'name', 'shortName', 'priceCents', 'category', 'sizes[]', 'images[]', 'cardImage', 'badge', 'stock', 'material', 'features[]', 'featured', 'sort', 'desc', 'metafields{}'] }, products: { rw: true, fields: ['slug', 'name', 'shortName', 'priceCents', 'mwst(0|7|19)', 'base_amount', 'base_unit', 'base_price_per', 'category', 'sizes[]', 'images[]', 'cardImage', 'badge', 'stock', 'material', 'features[]', 'featured', 'sort', 'desc', 'metafields{}'] },
pages: { rw: true, fields: ['slug', 'title', 'body', 'type(content|legal)', 'active', 'sort', 'blocks[]'] }, pages: { rw: true, fields: ['slug', 'title', 'body', 'type(content|legal)', 'active', 'sort', 'blocks[]'] },
slides: { rw: true, fields: ['image', 'headline', 'subline', 'link', 'sort', 'active'] }, slides: { rw: true, fields: ['image', 'headline', 'subline', 'link', 'sort', 'active'] },
popups: { rw: true, fields: ['title', 'type(newsletter|discount|announcement|exit)', 'headline', 'body', 'image', 'cta_text', 'cta_url', 'trigger', 'trigger_value', 'target_path', 'freq', 'active', 'sort', 'style(modal|slidein|bar)', 'discount_id'] }, popups: { rw: true, fields: ['title', 'type(newsletter|discount|announcement|exit)', 'headline', 'body', 'image', 'cta_text', 'cta_url', 'trigger', 'trigger_value', 'target_path', 'freq', 'active', 'sort', 'style(modal|slidein|bar)', 'discount_id'] },
discounts: { rw: true, fields: ['code', 'title', 'type(percent|fixed|freeshipping)', 'value', 'min_order_cents', 'starts_at', 'expires_at', 'max_uses', 'used_count', 'max_per_customer', 'active', 'secret', 'auto'] }, discounts: { rw: true, fields: ['code', 'title', 'type(percent|fixed|freeshipping)', 'value', 'min_order_cents', 'starts_at', 'expires_at', 'max_uses', 'used_count', 'max_per_customer', 'active', 'secret', 'auto'] },
shipping_zones: { rw: true, fields: ['name', 'countries(CSV ISO, EU)', 'price_cents', 'free_over_cents', 'delivery_days', 'sort', 'active'] },
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', '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'] },
}; };
@@ -33,6 +34,7 @@ export function listResource(name) {
case 'slides': return store.listSlides(); case 'slides': return store.listSlides();
case 'popups': return store.listPopups(); case 'popups': return store.listPopups();
case 'discounts': return store.listDiscounts(); case 'discounts': return store.listDiscounts();
case 'shipping_zones': return store.listShippingZones();
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();
@@ -46,6 +48,7 @@ export function getResource(name, id) {
case 'slides': return store.getSlideById(id); case 'slides': return store.getSlideById(id);
case 'popups': return store.getPopupById(id); case 'popups': return store.getPopupById(id);
case 'discounts': return store.getDiscountById(id); case 'discounts': return store.getDiscountById(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);
default: return null; default: return null;
@@ -77,6 +80,10 @@ 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 === 'shipping_zones') {
if (body.id) { store.updateShippingZone(body.id, body); return store.getShippingZoneById(body.id); }
const id = store.createShippingZone(body); return store.getShippingZoneById(id);
}
if (name === 'settings') { if (name === 'settings') {
const entries = body && typeof body === 'object' ? Object.entries(body) : []; const entries = body && typeof body === 'object' ? Object.entries(body) : [];
for (const [k, v] of entries) store.setSetting(k, v); for (const [k, v] of entries) store.setSetting(k, v);
@@ -92,6 +99,7 @@ export function deleteResource(name, id) {
case 'slides': store.deleteSlide(id); return true; case 'slides': store.deleteSlide(id); return true;
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;
default: throw new Error('Ressource nicht löschbar: ' + name); default: throw new Error('Ressource nicht löschbar: ' + name);
} }
} }
@@ -114,7 +122,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.1.0', version: '2.2.0',
auth: 'Authorization: Bearer <HDC_API_TOKEN>', auth: 'Authorization: Bearer <HDC_API_TOKEN>',
base_url: origin || '', base_url: origin || '',
resources: RESOURCES, resources: RESOURCES,
@@ -127,6 +135,9 @@ export function manifest(origin) {
'pages.blocks ist ein Array von Blöcken (siehe block_types) für den Visual-Builder.', 'pages.blocks ist ein Array von Blöcken (siehe block_types) für den Visual-Builder.',
'Block-Objekte sind FLACH: { type, <feldname>: ... } — NICHT unter einem data-Schlüssel verschachtelt.', 'Block-Objekte sind FLACH: { type, <feldname>: ... } — NICHT unter einem data-Schlüssel verschachtelt.',
'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.',
'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).',
], ],
}; };
} }
+2 -2
View File
@@ -75,8 +75,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', 'einstellungen', 'nutzer', 'audit'], owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'rabatte', 'inhalte', 'versandzonen', 'einstellungen', 'nutzer', 'audit'],
redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'rabatte', 'analytics'], redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'rabatte', 'analytics', 'versandzonen'],
versand: ['bestellungen'], versand: ['bestellungen'],
}; };
export function canAccess(role, section) { export function canAccess(role, section) {
+120
View File
@@ -0,0 +1,120 @@
// hd-commerce — Mailversand mit Provider-Abstraktion (Listmonk / SMTP / Log-Fallback).
// Ohne konfigurierte Zugangsdaten läuft alles über den Log-Fallback (email_log + console).
import { getSettings, getSetting, logEmail, formatPrice, taxFromGross } from './store.js';
export function mailerStatus() {
const provider = (process.env.MAIL_PROVIDER || '').trim().toLowerCase();
const from = (process.env.MAIL_FROM || '').trim();
if (provider === 'listmonk') {
const ok = !!(process.env.LISTMONK_URL && process.env.LISTMONK_USER && process.env.LISTMONK_PASS && process.env.LISTMONK_TX_TEMPLATE_ID);
return { provider: 'listmonk', configured: ok, from: from || '(MAIL_FROM fehlt)' };
}
if (provider === 'smtp') {
const ok = !!(process.env.SMTP_HOST && process.env.SMTP_PORT);
return { provider: 'smtp', configured: ok, from: from || (process.env.SMTP_USER || '') };
}
return { provider: 'log', configured: true, from: from || getSetting('shop_email', '') || '(Log-Fallback)' };
}
async function sendViaListmonk({ to, subject, html, type }) {
const url = String(process.env.LISTMONK_URL || '').replace(/\/+$/, '');
const auth = 'Basic ' + Buffer.from(`${process.env.LISTMONK_USER}:${process.env.LISTMONK_PASS}`).toString('base64');
const res = await fetch(url + '/api/tx', {
method: 'POST',
headers: { 'Authorization': auth, 'Content-Type': 'application/json' },
body: JSON.stringify({
subscriber_email: to,
template_id: Number(process.env.LISTMONK_TX_TEMPLATE_ID),
from_email: process.env.MAIL_FROM || undefined,
data: { subject, html, type },
}),
});
if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error('Listmonk ' + res.status + ': ' + t.slice(0, 200)); }
return true;
}
async function sendViaSmtp({ to, subject, html }) {
const nodemailer = (await import('nodemailer')).default;
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT) || 587,
secure: String(process.env.SMTP_SECURE || '').toLowerCase() === 'true' || Number(process.env.SMTP_PORT) === 465,
auth: (process.env.SMTP_USER || process.env.SMTP_PASS) ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } : undefined,
});
await transport.sendMail({ from: process.env.MAIL_FROM || process.env.SMTP_USER, to, subject, html });
return true;
}
// Einheitlicher Mailversand. Loggt IMMER in email_log (auch bei echtem Versand) für Nachvollziehbarkeit.
export async function sendMail({ to, subject, html, type = 'general' }) {
const st = mailerStatus();
let status = 'logged', provider = st.provider;
if (st.provider === 'listmonk' && st.configured) {
try { await sendViaListmonk({ to, subject, html, type }); status = 'sent'; }
catch (e) { status = 'error'; console.error('[mailer:listmonk]', e && e.message || e); }
} else if (st.provider === 'smtp' && st.configured) {
try { await sendViaSmtp({ to, subject, html }); status = 'sent'; }
catch (e) { status = 'error'; console.error('[mailer:smtp]', e && e.message || e); }
} else {
provider = 'log';
console.log('[mailer:log] →', to, '·', subject, '(' + type + ')');
}
logEmail({ recipient: to, subject, html, type, provider, status });
return { ok: status !== 'error', status, provider };
}
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
// Gebrandete Bestellbestätigung als HTML aus einem Order-Objekt erzeugen.
export function orderConfirmationHtml(order) {
const s = getSettings();
const shopName = s.shop_name || 'hd-commerce';
const accent = s.brand_accent || '#b8566a';
const items = Array.isArray(order.items) ? order.items : [];
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)}×</td>
<td style="padding:8px 0;border-bottom:1px solid #eee;text-align:right;white-space:nowrap">${esc(formatPrice(i.priceCents * i.qty))}</td>
</tr>`).join('');
const tax = Number(order.tax_cents) || 0;
const ship = Number(order.shipping_cents) || 0;
const disc = Number(order.discount_cents) || 0;
const total = Number(order.total_cents) || 0;
const subtotal = total + disc - ship;
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">Bestellbestätigung</div>
</div>
<div style="padding:28px">
<p style="margin:0 0 4px;font-size:16px">Vielen Dank für deine Bestellung${order.customer_name ? ', ' + esc(order.customer_name.split(' ')[0]) : ''}!</p>
<p style="margin:0 0 18px;color:#555">Bestellnummer <b style="color:#222">${esc(order.number)}</b></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:4px 0;color:#555">Zwischensumme</td><td style="padding:4px 0;text-align:right">${esc(formatPrice(subtotal))}</td></tr>
${disc > 0 ? `<tr><td style="padding:4px 0;color:${esc(accent)}">Rabatt${order.discount_code ? ' (' + esc(order.discount_code) + ')' : ''}</td><td style="padding:4px 0;text-align:right;color:${esc(accent)}">${esc(formatPrice(disc))}</td></tr>` : ''}
<tr><td style="padding:4px 0;color:#555">Versand</td><td style="padding:4px 0;text-align:right">${ship === 0 ? 'Kostenlos' : esc(formatPrice(ship))}</td></tr>
<tr><td style="padding:10px 0 0;font-weight:700;font-size:16px;border-top:1px solid #eee">Gesamt</td><td style="padding:10px 0 0;text-align:right;font-weight:700;font-size:16px;border-top:1px solid #eee">${esc(formatPrice(total))}</td></tr>
${tax > 0 ? `<tr><td style="padding:2px 0;color:#888;font-size:12px" colspan="2">inkl. ${esc(formatPrice(tax))} MwSt.</td></tr>` : ''}
</table>
${order.address ? `<div style="margin-top:20px;padding-top:16px;border-top:1px solid #eee"><div style="font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:#999;margin-bottom:4px">Lieferadresse</div><div style="font-size:14px;color:#444">${esc(order.address)}</div></div>` : ''}
<p style="margin:22px 0 0;font-size:13px;color:#888">Wir melden uns mit den Versanddetails. Bei Fragen einfach auf diese E-Mail antworten.</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>`;
}
// Bestellbestätigung versenden (idempotenz-Schutz liegt beim Aufrufer via markOrderPaid).
export async function sendOrderConfirmation(order) {
if (!order || !order.email) return { ok: false, status: 'no-recipient' };
const s = getSettings();
const subject = `Bestellbestätigung ${order.number} · ${s.shop_name || 'hd-commerce'}`;
const html = orderConfirmationHtml(order);
return sendMail({ to: order.email, subject, html, type: 'order_confirmation' });
}
+106
View File
@@ -0,0 +1,106 @@
// hd-commerce — Payment-Provider-Abstraktion.
// Einheitliche Schnittstelle für Mollie (Default), Stripe und Demo-Fallback.
// Provider-Wahl via Setting `payment_provider` / ENV `PAYMENT_PROVIDER`, sonst Auto nach vorhandenen Keys.
import { resolvePaymentProvider } from './store.js';
const MOLLIE_BASE = 'https://api.mollie.com/v2';
const mollieKeyReal = (k) => typeof k === 'string' && /^(test|live)_\w{20,}/.test(String(k).trim());
const stripeKeyReal = (k) => typeof k === 'string' && /^sk_(test|live)_[A-Za-z0-9]{16,}/.test(String(k).trim());
// Cent → Mollie-Amount-String "12.90"
function euro(cents) { return ((Math.round(Number(cents) || 0)) / 100).toFixed(2); }
// ---------- Mollie ----------
async function mollieCreate({ order, totalCents, returnUrl, webhookUrl, email, description }) {
const key = (process.env.MOLLIE_API_KEY || '').trim();
const res = await fetch(MOLLIE_BASE + '/payments', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: { currency: 'EUR', value: euro(totalCents) },
description: description || ('Bestellung ' + (order.number || '')),
redirectUrl: returnUrl,
webhookUrl: webhookUrl || undefined,
metadata: { order: order.number, order_id: order.id },
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error('Mollie ' + res.status + ': ' + (data && data.detail ? data.detail : 'Fehler'));
const url = data && data._links && data._links.checkout && data._links.checkout.href;
if (!url) throw new Error('Mollie: keine Checkout-URL');
return { redirectUrl: url, paymentId: data.id };
}
export async function molliePaymentStatus(paymentId) {
const key = (process.env.MOLLIE_API_KEY || '').trim();
const res = await fetch(MOLLIE_BASE + '/payments/' + encodeURIComponent(paymentId), {
headers: { 'Authorization': 'Bearer ' + key },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error('Mollie ' + res.status);
return { status: data.status, metadata: data.metadata || {}, id: data.id };
}
// ---------- Stripe ----------
async function stripeCreate({ order, lineItems, shippingCents, totalCents, discountCents, discountName, returnUrl, cancelUrl, email }) {
const secret = (process.env.STRIPE_SECRET_KEY || '').trim();
const Stripe = (await import('stripe')).default;
const stripe = new Stripe(secret);
const sessionCfg = {
mode: 'payment', payment_method_types: ['card'], locale: 'de',
customer_email: email || undefined,
line_items: [
...lineItems.map((i) => ({ quantity: i.qty, price_data: { currency: 'eur', unit_amount: i.priceCents,
product_data: { name: `${i.name}${i.size ? ' · ' + i.size : ''}` } } })),
...(shippingCents > 0 ? [{ quantity: 1, price_data: { currency: 'eur', unit_amount: shippingCents, product_data: { name: 'Versand' } } }] : []),
],
success_url: returnUrl,
cancel_url: cancelUrl,
metadata: { order_number: order.number },
};
if (discountCents > 0) {
try {
const coupon = await stripe.coupons.create({ amount_off: discountCents, currency: 'eur', duration: 'once', name: discountName || 'Rabatt' });
sessionCfg.discounts = [{ coupon: coupon.id }];
} catch {}
}
const session = await stripe.checkout.sessions.create(sessionCfg);
return { redirectUrl: session.url, paymentId: session.id };
}
// ---------- Einheitliche Schnittstelle ----------
// createPayment({ order, items, lineItems, totalCents, shippingCents, discountCents, discountName, returnUrl, demoUrl, cancelUrl, webhookUrl, email })
// → { redirectUrl, provider, paymentId } (bei Demo: redirectUrl = demoUrl)
export async function createPayment(opts) {
const { provider, keyReady } = resolvePaymentProvider();
const o = opts || {};
const demoUrl = o.demoUrl || (o.returnUrl ? o.returnUrl + (o.returnUrl.includes('?') ? '&' : '?') + 'demo=1' : '/bestellung-erfolgreich?demo=1');
if (provider === 'mollie' && keyReady && mollieKeyReal(process.env.MOLLIE_API_KEY || '')) {
try {
const r = await mollieCreate({
order: o.order, totalCents: o.totalCents, returnUrl: o.returnUrl,
webhookUrl: o.webhookUrl, email: o.email, description: o.description,
});
return { redirectUrl: r.redirectUrl, provider: 'mollie', paymentId: r.paymentId };
} catch (e) {
// Fake-/ungültiger Key oder API-Fehler → sauberer Demo-Fallback statt 500.
return { redirectUrl: demoUrl, provider: 'demo', paymentId: '', demo: true, fallbackReason: String(e && e.message || e) };
}
}
if (provider === 'stripe' && keyReady && stripeKeyReal(process.env.STRIPE_SECRET_KEY || '')) {
try {
const r = await stripeCreate({
order: o.order, lineItems: o.lineItems || [], shippingCents: o.shippingCents || 0,
totalCents: o.totalCents, discountCents: o.discountCents || 0, discountName: o.discountName,
returnUrl: o.returnUrl, cancelUrl: o.cancelUrl || '/warenkorb', email: o.email,
});
return { redirectUrl: r.redirectUrl, provider: 'stripe', paymentId: r.paymentId };
} catch (e) {
return { redirectUrl: demoUrl, provider: 'demo', paymentId: '', demo: true, fallbackReason: String(e && e.message || e) };
}
}
// Demo-Fallback (kein echter Key / Provider=demo)
return { redirectUrl: demoUrl, provider: 'demo', paymentId: '', demo: true };
}
+4 -2
View File
@@ -39,7 +39,8 @@ export const SEED_PRODUCTS = [
images: [img('naehgarn'), img('naehgarn2')], cardImage: img('naehgarn'), images: [img('naehgarn'), img('naehgarn2')], cardImage: img('naehgarn'),
badge: 'Set', stock: 60, material: '100 % Polyester, je 200 m', badge: 'Set', stock: 60, material: '100 % Polyester, je 200 m',
features: ['12 aufeinander abgestimmte Farben', 'Reißfest & universell', 'Für Hand- und Maschinennähen'], features: ['12 aufeinander abgestimmte Farben', 'Reißfest & universell', 'Für Hand- und Maschinennähen'],
featured: true, sort: 3, desc: 'Praktisches Allround-Set mit zwölf Nähgarnen in harmonischen Farbtönen. Geeignet für nahezu alle Stoffe und Projekte.' featured: true, sort: 3, desc: 'Praktisches Allround-Set mit zwölf Nähgarnen in harmonischen Farbtönen. Geeignet für nahezu alle Stoffe und Projekte.',
mwst: 19, base_amount: 240, base_unit: 'g', base_price_per: 'kg'
}, },
{ {
slug: 'knopf-sortiment-50', name: 'Knopf-Sortiment 50 Stück', shortName: 'Knopf-Sortiment 50', slug: 'knopf-sortiment-50', name: 'Knopf-Sortiment 50 Stück', shortName: 'Knopf-Sortiment 50',
@@ -47,7 +48,8 @@ export const SEED_PRODUCTS = [
images: [img('knoepfe'), img('knoepfe2')], cardImage: img('knoepfe'), images: [img('knoepfe'), img('knoepfe2')], cardImage: img('knoepfe'),
badge: '', stock: 45, material: 'Kunststoff & Holz, gemischt', badge: '', stock: 45, material: 'Kunststoff & Holz, gemischt',
features: ['Verschiedene Größen & Farben', '2- und 4-Loch-Knöpfe', 'Praktische Sortierbox'], features: ['Verschiedene Größen & Farben', '2- und 4-Loch-Knöpfe', 'Praktische Sortierbox'],
featured: false, sort: 4, desc: 'Bunt gemischtes Knopf-Sortiment mit 50 Stück in unterschiedlichen Größen, Farben und Materialien — für jedes Nähprojekt etwas dabei.' featured: false, sort: 4, desc: 'Bunt gemischtes Knopf-Sortiment mit 50 Stück in unterschiedlichen Größen, Farben und Materialien — für jedes Nähprojekt etwas dabei.',
mwst: 19, base_amount: 50, base_unit: 'Stk', base_price_per: '10 Stk'
}, },
{ {
slug: 'reissverschluss-set', name: 'Reißverschluss-Set (10 Stück)', shortName: 'Reißverschluss-Set', slug: 'reissverschluss-set', name: 'Reißverschluss-Set (10 Stück)', shortName: 'Reißverschluss-Set',
+228 -10
View File
@@ -92,8 +92,34 @@ ensureColumn('orders', 'discount_cents', "discount_cents INTEGER DEFAULT 0");
ensureColumn('popups', 'style', "style TEXT DEFAULT 'modal'"); ensureColumn('popups', 'style', "style TEXT DEFAULT 'modal'");
ensureColumn('popups', 'discount_id', "discount_id INTEGER"); ensureColumn('popups', 'discount_id', "discount_id INTEGER");
// v2.2 — DACH-Recht: MwSt-Satz, Grundpreis (PAngV)
ensureColumn('products', 'mwst', "mwst INTEGER DEFAULT 19");
ensureColumn('products', 'base_amount', "base_amount REAL");
ensureColumn('products', 'base_unit', "base_unit TEXT DEFAULT ''");
ensureColumn('products', 'base_price_per', "base_price_per TEXT DEFAULT ''");
// v2.2 — Orders: getrennter MwSt-/Versand-Ausweis, Land, Zahl-Provider
ensureColumn('orders', 'tax_cents', "tax_cents INTEGER DEFAULT 0");
ensureColumn('orders', 'shipping_cents', "shipping_cents INTEGER DEFAULT 0");
ensureColumn('orders', 'country', "country TEXT DEFAULT 'DE'");
ensureColumn('orders', 'payment_provider', "payment_provider TEXT DEFAULT ''");
ensureColumn('orders', 'payment_id', "payment_id TEXT DEFAULT ''");
// v2.2 — Versandzonen + E-Mail-Log
db.exec(`
CREATE TABLE IF NOT EXISTS shipping_zones (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, countries TEXT DEFAULT '',
price_cents INTEGER DEFAULT 0, free_over_cents INTEGER, delivery_days TEXT DEFAULT '',
sort INTEGER DEFAULT 99, active INTEGER DEFAULT 1, created_at TEXT
);
CREATE TABLE IF NOT EXISTS email_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, recipient TEXT, subject TEXT, html TEXT,
type TEXT DEFAULT 'general', provider TEXT DEFAULT 'log', status TEXT DEFAULT 'logged',
created_at TEXT
);
`);
// ---------- mappers ---------- // ---------- mappers ----------
const P = (r) => r && ({ ...r, sizes: JSON.parse(r.sizes || '[]'), images: JSON.parse(r.images || '[]'), features: JSON.parse(r.features || '[]'), metafields: JSON.parse(r.metafields || '{}'), featured: !!r.featured }); const P = (r) => r && ({ ...r, sizes: JSON.parse(r.sizes || '[]'), images: JSON.parse(r.images || '[]'), features: JSON.parse(r.features || '[]'), metafields: JSON.parse(r.metafields || '{}'), featured: !!r.featured, mwst: (r.mwst == null ? 19 : Number(r.mwst)) });
const O = (r) => r && ({ ...r, items: JSON.parse(r.items || '[]') }); const O = (r) => r && ({ ...r, items: JSON.parse(r.items || '[]') });
const E = (r) => r && ({ ...r, meta: JSON.parse(r.meta || '{}') }); const E = (r) => r && ({ ...r, meta: JSON.parse(r.meta || '{}') });
@@ -104,9 +130,9 @@ function seedIfEmpty() {
for (const [k, v] of Object.entries(SEED_SETTINGS)) ins.run(k, String(v)); for (const [k, v] of Object.entries(SEED_SETTINGS)) ins.run(k, String(v));
} }
if (db.prepare('SELECT COUNT(*) c FROM products').get().c === 0) { if (db.prepare('SELECT COUNT(*) c FROM products').get().c === 0) {
const ins = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,sizes,images,cardImage,badge,stock,material,features,featured,sort,desc,metafields) const ins = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,sizes,images,cardImage,badge,stock,material,features,featured,sort,desc,metafields,mwst,base_amount,base_unit,base_price_per)
VALUES (@slug,@name,@shortName,@priceCents,@category,@sizes,@images,@cardImage,@badge,@stock,@material,@features,@featured,@sort,@desc,@metafields)`); VALUES (@slug,@name,@shortName,@priceCents,@category,@sizes,@images,@cardImage,@badge,@stock,@material,@features,@featured,@sort,@desc,@metafields,@mwst,@base_amount,@base_unit,@base_price_per)`);
const tx = db.transaction(rows => rows.forEach(p => ins.run({ ...p, sizes: JSON.stringify(p.sizes), images: JSON.stringify(p.images), features: JSON.stringify(p.features), featured: p.featured ? 1 : 0, metafields: JSON.stringify(p.metafields || {}) }))); const tx = db.transaction(rows => rows.forEach(p => ins.run({ ...p, sizes: JSON.stringify(p.sizes), images: JSON.stringify(p.images), features: JSON.stringify(p.features), featured: p.featured ? 1 : 0, metafields: JSON.stringify(p.metafields || {}), mwst: (p.mwst == null ? 19 : p.mwst), base_amount: (p.base_amount == null ? null : p.base_amount), base_unit: p.base_unit || '', base_price_per: p.base_price_per || '' })));
tx(SEED_PRODUCTS); tx(SEED_PRODUCTS);
} }
if (db.prepare('SELECT COUNT(*) c FROM customers').get().c === 0) { if (db.prepare('SELECT COUNT(*) c FROM customers').get().c === 0) {
@@ -133,6 +159,7 @@ function seedIfEmpty() {
SEED_POPUPS.forEach(p => ip.run({ ...p, created_at: now })); SEED_POPUPS.forEach(p => ip.run({ ...p, created_at: now }));
} }
if (db.prepare('SELECT COUNT(*) c FROM discounts').get().c === 0) seedDiscounts(); if (db.prepare('SELECT COUNT(*) c FROM discounts').get().c === 0) seedDiscounts();
if (db.prepare('SELECT COUNT(*) c FROM shipping_zones').get().c === 0) seedShippingZones();
// seed some demo analytics events so the analytics dashboard is not empty // seed some demo analytics events so the analytics dashboard is not empty
if (db.prepare('SELECT COUNT(*) c FROM events').get().c === 0) seedEvents(); if (db.prepare('SELECT COUNT(*) c FROM events').get().c === 0) seedEvents();
} }
@@ -342,15 +369,19 @@ function normProduct(d) {
badge: d.badge || '', stock: (d.stock === '' || d.stock == null) ? null : Math.round(Number(d.stock)), material: d.material || '', badge: d.badge || '', stock: (d.stock === '' || d.stock == null) ? null : Math.round(Number(d.stock)), material: d.material || '',
features: JSON.stringify(d.features || []), featured: d.featured ? 1 : 0, sort: Number(d.sort) || 99, desc: d.desc || '', features: JSON.stringify(d.features || []), featured: d.featured ? 1 : 0, sort: Number(d.sort) || 99, desc: d.desc || '',
metafields: JSON.stringify(d.metafields || {}), metafields: JSON.stringify(d.metafields || {}),
mwst: ([7, 19, 0].includes(Math.round(Number(d.mwst)))) ? Math.round(Number(d.mwst)) : 19,
base_amount: (d.base_amount === '' || d.base_amount == null) ? null : Number(d.base_amount),
base_unit: d.base_unit || '',
base_price_per: d.base_price_per || '',
}; };
} }
export function createProduct(d) { export function createProduct(d) {
const r = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,sizes,images,cardImage,badge,stock,material,features,featured,sort,desc,metafields) const r = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,sizes,images,cardImage,badge,stock,material,features,featured,sort,desc,metafields,mwst,base_amount,base_unit,base_price_per)
VALUES (@slug,@name,@shortName,@priceCents,@category,@sizes,@images,@cardImage,@badge,@stock,@material,@features,@featured,@sort,@desc,@metafields)`).run(normProduct(d)); VALUES (@slug,@name,@shortName,@priceCents,@category,@sizes,@images,@cardImage,@badge,@stock,@material,@features,@featured,@sort,@desc,@metafields,@mwst,@base_amount,@base_unit,@base_price_per)`).run(normProduct(d));
return r.lastInsertRowid; return r.lastInsertRowid;
} }
export function updateProduct(id, d) { export function updateProduct(id, d) {
db.prepare(`UPDATE products SET slug=@slug,name=@name,shortName=@shortName,priceCents=@priceCents,category=@category,sizes=@sizes,images=@images,cardImage=@cardImage,badge=@badge,stock=@stock,material=@material,features=@features,featured=@featured,sort=@sort,desc=@desc,metafields=@metafields WHERE id=@id`) db.prepare(`UPDATE products SET slug=@slug,name=@name,shortName=@shortName,priceCents=@priceCents,category=@category,sizes=@sizes,images=@images,cardImage=@cardImage,badge=@badge,stock=@stock,material=@material,features=@features,featured=@featured,sort=@sort,desc=@desc,metafields=@metafields,mwst=@mwst,base_amount=@base_amount,base_unit=@base_unit,base_price_per=@base_price_per WHERE id=@id`)
.run({ ...normProduct(d), id: Number(id) }); .run({ ...normProduct(d), id: Number(id) });
return id; return id;
} }
@@ -360,19 +391,40 @@ export const deleteProduct = (id) => db.prepare('DELETE FROM products WHERE id=?
export const listOrders = () => db.prepare('SELECT * FROM orders ORDER BY datetime(created_at) DESC, id DESC').all().map(O); export const listOrders = () => db.prepare('SELECT * FROM orders ORDER BY datetime(created_at) DESC, id DESC').all().map(O);
export const getOrderById = (id) => O(db.prepare('SELECT * FROM orders WHERE id=?').get(Number(id))); export const getOrderById = (id) => O(db.prepare('SELECT * FROM orders WHERE id=?').get(Number(id)));
export const getOrderByNumber = (num) => O(db.prepare('SELECT * FROM orders WHERE number=?').get(num)); export const getOrderByNumber = (num) => O(db.prepare('SELECT * FROM orders WHERE number=?').get(num));
export function createOrder({ email, customer_name, items, total_cents, status = 'pending', address = '', discount_code = '', discount_cents = 0 }) { export function createOrder({ email, customer_name, items, total_cents, status = 'pending', address = '', discount_code = '', discount_cents = 0, tax_cents = 0, shipping_cents = 0, country = 'DE', payment_provider = '', payment_id = '' }) {
const m = db.prepare("SELECT MAX(CAST(substr(number,5) AS INTEGER)) m FROM orders").get().m || 1000; const m = db.prepare("SELECT MAX(CAST(substr(number,5) AS INTEGER)) m FROM orders").get().m || 1000;
const number = 'BNK-' + (m + 1); const number = 'BNK-' + (m + 1);
const now = new Date().toISOString(); const now = new Date().toISOString();
const r = db.prepare('INSERT INTO orders (number,email,customer_name,status,total_cents,items,address,created_at,discount_code,discount_cents) VALUES (?,?,?,?,?,?,?,?,?,?)') const r = db.prepare('INSERT INTO orders (number,email,customer_name,status,total_cents,items,address,created_at,discount_code,discount_cents,tax_cents,shipping_cents,country,payment_provider,payment_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)')
.run(number, email || '', customer_name || '', status, total_cents || 0, JSON.stringify(items || []), address || '', now, discount_code || '', Math.round(Number(discount_cents) || 0)); .run(number, email || '', customer_name || '', status, total_cents || 0, JSON.stringify(items || []), address || '', now,
discount_code || '', Math.round(Number(discount_cents) || 0), Math.round(Number(tax_cents) || 0), Math.round(Number(shipping_cents) || 0),
country || 'DE', payment_provider || '', payment_id || '');
if (email) { if (email) {
db.prepare('INSERT OR IGNORE INTO customers (name,email,city,created_at) VALUES (?,?,?,?)').run(customer_name || '', email, '', now); db.prepare('INSERT OR IGNORE INTO customers (name,email,city,created_at) VALUES (?,?,?,?)').run(customer_name || '', email, '', now);
} }
// Umsatz-Event nur fuer bezahlte/Demo-Abschluesse; pending bleibt unberuecksichtigt bis paid.
if (status === 'paid' || status === 'fulfilled') {
recordEvent({ type: 'purchase', path: '/bestellung-erfolgreich', value_cents: total_cents || 0, meta: { number } }); recordEvent({ type: 'purchase', path: '/bestellung-erfolgreich', value_cents: total_cents || 0, meta: { number } });
}
return { id: r.lastInsertRowid, number }; return { id: r.lastInsertRowid, number };
} }
export const updateOrderStatus = (id, status) => db.prepare('UPDATE orders SET status=? WHERE id=?').run(status, Number(id)); export const updateOrderStatus = (id, status) => db.prepare('UPDATE orders SET status=? WHERE id=?').run(status, Number(id));
export const getOrderByPaymentId = (pid) => O(db.prepare('SELECT * FROM orders WHERE payment_id=?').get(String(pid || '')));
export function setOrderPayment(id, { payment_id = '', payment_provider = '' } = {}) {
db.prepare("UPDATE orders SET payment_id=COALESCE(NULLIF(?,''),payment_id), payment_provider=COALESCE(NULLIF(?,''),payment_provider) WHERE id=?")
.run(String(payment_id || ''), String(payment_provider || ''), Number(id));
return id;
}
// Idempotent: setzt eine Order auf bezahlt, falls noch nicht. Gibt true zurueck, wenn jetzt gerade umgestellt wurde.
export function markOrderPaid(id, { payment_id = '', payment_provider = '' } = {}) {
const cur = db.prepare('SELECT * FROM orders WHERE id=?').get(Number(id));
if (!cur) return { ok: false, changed: false };
if (cur.status === 'paid' || cur.status === 'fulfilled') return { ok: true, changed: false, order: O(cur) };
db.prepare("UPDATE orders SET status='paid', payment_id=COALESCE(NULLIF(?,''),payment_id), payment_provider=COALESCE(NULLIF(?,''),payment_provider) WHERE id=?")
.run(String(payment_id || ''), String(payment_provider || ''), Number(id));
recordEvent({ type: 'purchase', path: '/bestellung-erfolgreich', value_cents: cur.total_cents || 0, meta: { number: cur.number } });
return { ok: true, changed: true, order: O(db.prepare('SELECT * FROM orders WHERE id=?').get(Number(id))) };
}
// ---------- customers ---------- // ---------- customers ----------
export function listCustomers() { export function listCustomers() {
@@ -632,3 +684,169 @@ export function recordAudit({ user = '', action = '', entity = '', entity_id = '
} }
export const listAudit = (limit = 200) => db.prepare('SELECT * FROM audit ORDER BY id DESC LIMIT ?').all(Number(limit) || 200); export const listAudit = (limit = 200) => db.prepare('SELECT * FROM audit ORDER BY id DESC LIMIT ?').all(Number(limit) || 200);
export const recentAudit = (limit = 8) => db.prepare('SELECT * FROM audit ORDER BY id DESC LIMIT ?').all(Number(limit) || 8); export const recentAudit = (limit = 8) => db.prepare('SELECT * FROM audit ORDER BY id DESC LIMIT ?').all(Number(limit) || 8);
// =====================================================================
// v2.2 — Versandzonen, MwSt/Grundpreis, Feature-Flags, E-Mail-Log
// =====================================================================
// ---------- shipping zones ----------
function seedShippingZones() {
const now = new Date().toISOString();
const rows = [
{ name: 'Deutschland', countries: 'DE', price_cents: 490, free_over_cents: 4900, delivery_days: '24', sort: 1, active: 1 },
{ name: 'Österreich & Schweiz', countries: 'AT,CH', price_cents: 990, free_over_cents: null, delivery_days: '47', sort: 2, active: 1 },
{ name: 'EU', countries: 'EU', price_cents: 1290, free_over_cents: null, delivery_days: '59', sort: 3, active: 1 },
];
const ins = db.prepare(`INSERT INTO shipping_zones (name,countries,price_cents,free_over_cents,delivery_days,sort,active,created_at)
VALUES (@name,@countries,@price_cents,@free_over_cents,@delivery_days,@sort,@active,@created_at)`);
const tx = db.transaction(() => rows.forEach(r => ins.run({ ...r, created_at: now })));
tx();
}
if (db.prepare('SELECT COUNT(*) c FROM shipping_zones').get().c === 0) seedShippingZones();
const SZ = (r) => r && ({ ...r, active: !!r.active });
export const listShippingZones = () => db.prepare('SELECT * FROM shipping_zones ORDER BY sort, id').all().map(SZ);
export const listActiveShippingZones = () => db.prepare('SELECT * FROM shipping_zones WHERE active=1 ORDER BY sort, id').all().map(SZ);
export const getShippingZoneById = (id) => SZ(db.prepare('SELECT * FROM shipping_zones WHERE id=?').get(Number(id)));
function normZone(d) {
const numOrNull = (v) => (v === '' || v == null) ? null : Math.round(Number(v));
return {
name: String(d.name || '').trim() || 'Zone',
countries: String(d.countries || '').toUpperCase().replace(/\s+/g, ''),
price_cents: Math.max(0, Math.round(Number(d.price_cents) || 0)),
free_over_cents: numOrNull(d.free_over_cents),
delivery_days: String(d.delivery_days || ''),
sort: Number(d.sort) || 99,
active: d.active ? 1 : 0,
};
}
export function createShippingZone(d) {
const n = normZone(d);
const r = db.prepare(`INSERT INTO shipping_zones (name,countries,price_cents,free_over_cents,delivery_days,sort,active,created_at)
VALUES (@name,@countries,@price_cents,@free_over_cents,@delivery_days,@sort,@active,@created_at)`).run({ ...n, created_at: new Date().toISOString() });
return r.lastInsertRowid;
}
export function updateShippingZone(id, d) {
const cur = db.prepare('SELECT * FROM shipping_zones WHERE id=?').get(Number(id));
if (!cur) return id;
const n = normZone({ ...cur, ...d });
db.prepare(`UPDATE shipping_zones SET name=@name,countries=@countries,price_cents=@price_cents,free_over_cents=@free_over_cents,
delivery_days=@delivery_days,sort=@sort,active=@active WHERE id=@id`).run({ ...n, id: Number(id) });
return id;
}
export const deleteShippingZone = (id) => db.prepare('DELETE FROM shipping_zones WHERE id=?').run(Number(id));
// EU-Mitgliedsstaaten (für die generische "EU"-Zone)
const EU_COUNTRIES = new Set(['AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IE','IT','LV','LT','LU','MT','NL','PL','PT','RO','SK','SI','ES','SE']);
function zoneMatchesCountry(zone, cc) {
const code = String(cc || '').toUpperCase().trim();
if (!code) return false;
const list = String(zone.countries || '').toUpperCase().split(',').map(x => x.trim()).filter(Boolean);
if (list.includes(code)) return true;
if (list.includes('EU') && EU_COUNTRIES.has(code)) return true;
if (list.includes('*')) return true;
return false;
}
// Liefert die passende Zone + berechneten Versandpreis für ein Land bei gegebener Zwischensumme.
export function shippingFor(countryCode, subtotalCents) {
const sub = Math.max(0, Math.round(Number(subtotalCents) || 0));
const cc = String(countryCode || 'DE').toUpperCase().trim();
const zones = listActiveShippingZones();
// exakte Länderzuordnung schlägt generische EU-/Wildcard-Zone
let exact = zones.find(z => {
const list = String(z.countries || '').toUpperCase().split(',').map(x => x.trim());
return list.includes(cc);
});
let zone = exact || zones.find(z => zoneMatchesCountry(z, cc)) || null;
if (!zone) return { ok: false, zone: null, price_cents: 0, free: false, delivery_days: '' };
const free = zone.free_over_cents != null && sub >= zone.free_over_cents;
return {
ok: true, zone_id: zone.id, zone_name: zone.name,
price_cents: free ? 0 : zone.price_cents, base_price_cents: zone.price_cents,
free, free_over_cents: zone.free_over_cents, delivery_days: zone.delivery_days,
};
}
// ---------- MwSt / Grundpreis ----------
// Aus einem Brutto-Betrag die enthaltene MwSt herausrechnen (Satz in Prozent).
export function taxFromGross(grossCents, rate) {
const r = Number(rate) || 0;
if (r <= 0) return 0;
return Math.round((Number(grossCents) || 0) * r / (100 + r));
}
// Grundpreis (PAngV) als String, z.B. "8,90 €/kg". base: { base_amount, base_unit, base_price_per }
export function basePriceLabel(priceCents, p) {
const amount = Number(p && p.base_amount);
const unit = String((p && p.base_unit) || '').trim();
const per = String((p && p.base_price_per) || '').trim();
if (!amount || !unit || !per) return '';
// per kann "kg", "l", "100 g" etc. sein → Zielmenge in derselben Einheit interpretieren.
const m = per.match(/^([\d.,]+)?\s*(.+)$/);
if (!m) return '';
const perQty = m[1] ? parseFloat(m[1].replace(',', '.')) : 1;
const perUnit = m[2].trim();
// base_amount ist in base_unit; Umrechnungsfaktor g↔kg, ml↔l unterstützt.
const factors = { g: { kg: 1000, g: 1 }, kg: { kg: 1, g: 0.001 }, ml: { l: 1000, ml: 1 }, l: { l: 1, ml: 0.001 } };
let perPriceCents;
const fu = factors[unit.toLowerCase()];
if (fu && fu[perUnit.toLowerCase()] != null) {
const unitsOfBasePerTarget = fu[perUnit.toLowerCase()] * perQty;
perPriceCents = (Number(priceCents) || 0) / amount * unitsOfBasePerTarget;
} else {
// gleiche Einheit / keine Umrechnung: Preis pro perQty Einheiten
perPriceCents = (Number(priceCents) || 0) / amount * perQty;
}
const label = formatPrice(Math.round(perPriceCents));
return `${label}/${per}`;
}
// ---------- Feature-Flags ----------
const FEATURE_DEFAULTS = {
feature_newsletter: '1',
feature_accounts: '0',
feature_reviews: '0',
feature_wishlist: '0',
feature_abandoned_cart: '0',
feature_search: '0',
};
export const FEATURE_KEYS = Object.keys(FEATURE_DEFAULTS);
export function feature(key) {
if (!(key in FEATURE_DEFAULTS)) return false;
const v = getSetting(key, FEATURE_DEFAULTS[key]);
return v === '1' || v === 1 || v === true || v === 'true';
}
export function allFeatures() {
const o = {};
for (const k of FEATURE_KEYS) o[k] = feature(k);
return o;
}
// ---------- payment provider (Setting/ENV-Auflösung) ----------
const mollieKeyReal = (k) => typeof k === 'string' && /^(test|live)_\w{20,}/.test(String(k).trim());
const stripeKeyReal = (k) => typeof k === 'string' && /^sk_(test|live)_[A-Za-z0-9]{16,}/.test(String(k).trim());
// Welcher Provider ist aktiv? Setting payment_provider > ENV PAYMENT_PROVIDER > Auto-Wahl nach Keys.
export function resolvePaymentProvider() {
const setting = (getSetting('payment_provider', '') || '').trim().toLowerCase();
const envProv = (process.env.PAYMENT_PROVIDER || '').trim().toLowerCase();
const chosen = setting || envProv || '';
const mollie = mollieKeyReal(process.env.MOLLIE_API_KEY || '');
const stripe = stripeKeyReal(process.env.STRIPE_SECRET_KEY || '');
if (chosen === 'mollie') return { provider: 'mollie', keyReady: mollie, source: setting ? 'setting' : 'env' };
if (chosen === 'stripe') return { provider: 'stripe', keyReady: stripe, source: setting ? 'setting' : 'env' };
if (chosen === 'demo') return { provider: 'demo', keyReady: true, source: setting ? 'setting' : 'env' };
// Auto-Wahl
if (mollie) return { provider: 'mollie', keyReady: true, source: 'auto' };
if (stripe) return { provider: 'stripe', keyReady: true, source: 'auto' };
return { provider: 'demo', keyReady: true, source: 'auto' };
}
// ---------- E-Mail-Log ----------
export function logEmail({ recipient = '', subject = '', html = '', type = 'general', provider = 'log', status = 'logged' }) {
try {
const r = db.prepare('INSERT INTO email_log (recipient,subject,html,type,provider,status,created_at) VALUES (?,?,?,?,?,?,?)')
.run(String(recipient || ''), String(subject || ''), String(html || ''), String(type || 'general'), String(provider || 'log'), String(status || 'logged'), new Date().toISOString());
return r.lastInsertRowid;
} catch { return null; }
}
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));
+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', 'konto': 'dashboard', 'login': 'login', 'logout': 'logout', 'nutzer': 'nutzer', 'audit': 'audit', 'versand': 'versandzonen', 'konto': 'dashboard', 'login': 'login', 'logout': 'logout',
}; };
return map[seg] || 'dashboard'; return map[seg] || 'dashboard';
} }
+7 -1
View File
@@ -13,6 +13,7 @@ if (Astro.request.method === 'POST') {
} }
const order = getOrderById(id); const order = getOrderById(id);
if (!order) return Astro.redirect(base + '/bestellungen'); if (!order) return Astro.redirect(base + '/bestellungen');
const subtotal = order.total_cents + (order.discount_cents || 0) - (order.shipping_cents || 0);
const statusMap = { fulfilled: ['green', 'Erfüllt'], pending: ['amber', 'Offen'], cancelled: ['gray', 'Storniert'], refunded: ['red', 'Erstattet'] }; const statusMap = { fulfilled: ['green', 'Erfüllt'], pending: ['amber', 'Offen'], cancelled: ['gray', 'Storniert'], refunded: ['red', 'Erstattet'] };
const fmtDate = (s) => new Date(s).toLocaleString('de-DE', { day: '2-digit', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }); const fmtDate = (s) => new Date(s).toLocaleString('de-DE', { day: '2-digit', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' });
const statuses = [['pending', 'Offen'], ['fulfilled', 'Erfüllt'], ['cancelled', 'Storniert'], ['refunded', 'Erstattet']]; const statuses = [['pending', 'Offen'], ['fulfilled', 'Erfüllt'], ['cancelled', 'Storniert'], ['refunded', 'Erstattet']];
@@ -39,7 +40,12 @@ const statuses = [['pending', 'Offen'], ['fulfilled', 'Erfüllt'], ['cancelled',
{(order.discount_cents === 0 && order.discount_code) && ( {(order.discount_cents === 0 && order.discount_code) && (
<div class="s-card-pad" style="display:flex;justify-content:space-between;color:var(--accent);font-size:14px;border-top:1px solid var(--s-border)"><span>Gutschein ({order.discount_code})</span><span>Gratisversand</span></div> <div class="s-card-pad" style="display:flex;justify-content:space-between;color:var(--accent);font-size:14px;border-top:1px solid var(--s-border)"><span>Gutschein ({order.discount_code})</span><span>Gratisversand</span></div>
)} )}
<div class="s-card-pad" style="display:flex;justify-content:space-between;font-weight:700;font-size:16px;border-top:1px solid var(--s-border)"><span>Gesamt</span><span>{formatPrice(order.total_cents)}</span></div> <div class="s-card-pad" style="display:flex;justify-content:space-between;font-size:14px;color:var(--s-muted,#777);border-top:1px solid var(--s-border)"><span>Zwischensumme (netto + brutto)</span><span>{formatPrice(subtotal)}</span></div>
<div class="s-card-pad" style="display:flex;justify-content:space-between;font-size:14px;color:var(--s-muted,#777);padding-top:0"><span>Versand{order.country ? ` (${order.country})` : ''}</span><span>{order.shipping_cents === 0 ? 'Kostenlos' : formatPrice(order.shipping_cents)}</span></div>
<div class="s-card-pad" style="display:flex;justify-content:space-between;font-weight:700;font-size:16px;border-top:1px solid var(--s-border)"><span>Gesamt (brutto)</span><span>{formatPrice(order.total_cents)}</span></div>
<div class="s-card-pad" style="display:flex;justify-content:space-between;font-size:13px;color:var(--s-muted,#777);padding-top:0">
<span>Netto / enthaltene MwSt</span><span>{formatPrice(order.total_cents - (order.tax_cents||0))} / {formatPrice(order.tax_cents||0)}</span>
</div>
</div> </div>
<div class="s-stack"> <div class="s-stack">
@@ -0,0 +1,52 @@
---
import Admin from '../../../layouts/Admin.astro';
import { adminBase } from '../../../lib/auth.js';
const base = adminBase();
import { listEmailLog } from '../../../lib/store.js';
const logs = listEmailLog(100);
const fmtDate = (s) => { try { return new Date(s).toLocaleString('de-DE', { day: '2-digit', month: 'short', year: '2-digit', hour: '2-digit', minute: '2-digit' }); } catch { return s; } };
const typeLabel = { order_confirmation: 'Bestellbestätigung', general: 'Allgemein' };
const statusBadge = { sent: 'green', logged: 'amber', error: 'red' };
const url = new URL(Astro.request.url);
const previewId = url.searchParams.get('preview');
const preview = previewId ? logs.find((l) => String(l.id) === String(previewId)) : null;
---
<Admin title="E-Mail-Log" active="einstellungen" crumbs={[{ label: 'Einstellungen', href: base + '/einstellungen' }, { label: 'E-Mail-Log' }]}>
<div class="s-stack">
<div class="s-card s-card-pad">
<p class="s-help" style="margin:0">Letzte gesendete bzw. geloggte Mails. Ohne konfigurierten Provider (Listmonk/SMTP) werden alle Mails hier protokolliert (Status „logged").</p>
</div>
{preview && (
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:8px">Vorschau — {preview.subject}</div>
<p class="s-help" style="margin:0 0 10px">An {preview.recipient} · {fmtDate(preview.created_at)}</p>
<iframe srcdoc={preview.html} style="width:100%;height:520px;border:1px solid var(--s-border);border-radius:10px;background:#fff"></iframe>
<p style="margin-top:10px"><a class="s-btn s-btn-sm" href={base + '/einstellungen/email-log'}>Vorschau schließen</a></p>
</div>
)}
<div class="s-card">
<div class="s-card-head">Protokoll ({logs.length})</div>
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Datum</th><th>Empfänger</th><th>Betreff</th><th>Typ</th><th>Provider</th><th>Status</th><th></th></tr></thead>
<tbody>
{logs.length === 0 ? (<tr><td colspan="7" class="s-empty">Noch keine Mails</td></tr>) :
logs.map((l) => (
<tr>
<td class="s-muted" style="white-space:nowrap">{fmtDate(l.created_at)}</td>
<td>{l.recipient}</td>
<td><b>{l.subject}</b></td>
<td class="s-muted">{typeLabel[l.type] || l.type}</td>
<td class="s-muted">{l.provider}</td>
<td><span class={`s-badge ${statusBadge[l.status] || 'gray'}`}>{l.status}</span></td>
<td style="text-align:right"><a class="s-btn s-btn-sm" href={base + '/einstellungen/email-log?preview=' + l.id}>Ansehen</a></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</Admin>
+72 -12
View File
@@ -2,11 +2,29 @@
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 } from '../../../lib/store.js'; import { getSettings, setSetting, resolvePaymentProvider, FEATURE_KEYS, feature } from '../../../lib/store.js';
import { mailerStatus } from '../../../lib/mailer.js';
const FEATURE_LABELS = {
feature_newsletter: ['Newsletter', 'Newsletter-Popup & Anmeldung im Storefront'],
feature_accounts: ['Kundenkonten', 'Registrierung & Login für Kund:innen (in Vorbereitung)'],
feature_reviews: ['Bewertungen', 'Produktbewertungen (in Vorbereitung)'],
feature_wishlist: ['Merkliste', 'Wunschliste / Merken (in Vorbereitung)'],
feature_abandoned_cart: ['Warenkorb-Erinnerung', 'Abandoned-Cart-Mails (in Vorbereitung)'],
feature_search: ['Suche', 'Produktsuche im Storefront (in Vorbereitung)'],
};
let flash = ''; let flash = '';
if (Astro.request.method === 'POST') { if (Astro.request.method === 'POST') {
const f = await Astro.request.formData(); const f = await Astro.request.formData();
const action = String(f.get('_action') || 'general');
if (action === 'features') {
for (const k of FEATURE_KEYS) setSetting(k, f.get(k) === 'on' ? '1' : '0');
flash = 'Module aktualisiert.';
} else if (action === 'payment') {
setSetting('payment_provider', String(f.get('payment_provider') || ''));
flash = 'Zahlungsanbieter gespeichert.';
} else {
setSetting('shop_name', f.get('shop_name') || 'hd-commerce'); setSetting('shop_name', f.get('shop_name') || 'hd-commerce');
setSetting('shop_tagline', f.get('shop_tagline') || ''); setSetting('shop_tagline', f.get('shop_tagline') || '');
setSetting('shop_email', f.get('shop_email') || ''); setSetting('shop_email', f.get('shop_email') || '');
@@ -16,13 +34,19 @@ if (Astro.request.method === 'POST') {
setSetting('free_shipping_cents', String(Math.round(parseFloat(String(f.get('free_shipping') || '49').replace(',', '.')) * 100) || 4900)); setSetting('free_shipping_cents', String(Math.round(parseFloat(String(f.get('free_shipping') || '49').replace(',', '.')) * 100) || 4900));
flash = 'Einstellungen gespeichert.'; flash = 'Einstellungen gespeichert.';
} }
}
const s = getSettings(); const s = getSettings();
const stripeSecret = (process.env.STRIPE_SECRET_KEY || '').trim();
const stripeReal = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test(stripeSecret);
const stripeMode = stripeReal ? (stripeSecret.startsWith('sk_live') ? 'Live' : 'Test') : 'Demo-Fallback';
const freeShipStr = ((Number(s.free_shipping_cents) || 4900) / 100).toFixed(2).replace('.', ','); const freeShipStr = ((Number(s.free_shipping_cents) || 4900) / 100).toFixed(2).replace('.', ',');
const currencies = ['EUR', 'CHF', 'USD', 'GBP']; const currencies = ['EUR', 'CHF', 'USD', 'GBP'];
const pp = resolvePaymentProvider();
const providerLabel = { mollie: 'Mollie', stripe: 'Stripe', demo: 'Demo-Fallback' }[pp.provider] || pp.provider;
const mollieSet = /^(test|live)_\w{20,}/.test((process.env.MOLLIE_API_KEY || '').trim());
const stripeSet = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test((process.env.STRIPE_SECRET_KEY || '').trim());
const providerSetting = s.payment_provider || '';
const mail = mailerStatus();
--- ---
<Admin title="Einstellungen" active="einstellungen" crumbs={[{ label: 'Einstellungen' }]}> <Admin title="Einstellungen" active="einstellungen" crumbs={[{ label: 'Einstellungen' }]}>
<div class="s-stack"> <div class="s-stack">
@@ -51,26 +75,62 @@ const currencies = ['EUR', 'CHF', 'USD', 'GBP'];
<div class="s-field"><label class="s-label">Währung</label><select class="s-select" name="currency">{currencies.map((c) => (<option value={c} selected={s.currency === c}>{c}</option>))}</select></div> <div class="s-field"><label class="s-label">Währung</label><select class="s-select" name="currency">{currencies.map((c) => (<option value={c} selected={s.currency === c}>{c}</option>))}</select></div>
<div class="s-field"><label class="s-label">Gratis-Versand ab (€)</label><input class="s-input" name="free_shipping" value={freeShipStr} /></div> <div class="s-field"><label class="s-label">Gratis-Versand ab (€)</label><input class="s-input" name="free_shipping" value={freeShipStr} /></div>
</div> </div>
<div class="s-help">Versandzonen & länderabhängige Preise unter „Versand".</div>
</div> </div>
<button class="s-btn s-btn-primary" type="submit" style="align-self:flex-start">Alle Einstellungen speichern</button> <button class="s-btn s-btn-primary" type="submit" style="align-self:flex-start">Einstellungen speichern</button>
</div> </div>
<div class="s-stack"> <div class="s-stack">
<div class="s-card s-card-pad"> <div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:12px">Zahlung (Stripe)</div> <div class="s-section-title" style="margin-bottom:12px">Zahlung</div>
<p style="margin:0 0 8px"><span class={`s-badge ${stripeReal ? 'green' : 'amber'}`}>{stripeMode}</span></p> <p style="margin:0 0 10px"><span class={`s-badge ${pp.provider === 'demo' ? 'amber' : 'green'}`}>Aktiv: {providerLabel}</span> <span class="s-badge gray" style="margin-left:6px">{pp.source === 'auto' ? 'Auto-Wahl' : pp.source === 'setting' ? 'manuell' : 'ENV'}</span></p>
<p class="s-help">{stripeReal ? 'Echter Stripe-Schlüssel erkannt — Checkout nutzt Stripe Hosted Checkout.' : 'Kein echter STRIPE_SECRET_KEY gesetzt. Der Checkout läuft im Demo-Fallback (Bestellung ohne Zahlung).'}</p> <div class="s-field"><label class="s-label">Anbieter</label>
<p class="s-help" style="margin-top:8px">Konfiguration über ENV: <b>STRIPE_SECRET_KEY</b>, <b>STRIPE_PUBLIC_KEY</b>.</p> <select class="s-select" name="payment_provider" form="payForm">
<option value="" selected={providerSetting === ''}>Automatisch (nach Keys)</option>
<option value="mollie" selected={providerSetting === 'mollie'}>Mollie</option>
<option value="stripe" selected={providerSetting === 'stripe'}>Stripe</option>
<option value="demo" selected={providerSetting === 'demo'}>Demo (ohne Zahlung)</option>
</select>
</div> </div>
<ul class="s-help" style="margin:8px 0 0;padding-left:16px;line-height:1.7">
<li>Mollie-Key (MOLLIE_API_KEY): <b>{mollieSet ? 'gesetzt' : 'fehlt'}</b></li>
<li>Stripe-Key (STRIPE_SECRET_KEY): <b>{stripeSet ? 'gesetzt' : 'fehlt'}</b></li>
</ul>
{pp.provider === 'demo' && <p class="s-help" style="margin-top:8px">Ohne gültigen Key läuft der Checkout im Demo-Fallback (Bestellung ohne echte Zahlung).</p>}
</div>
<div class="s-card s-card-pad"> <div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:12px">Analytics</div> <div class="s-section-title" style="margin-bottom:12px">E-Mail-Versand</div>
<p class="s-help">hd-commerce nutzt eine eigene First-Party-Statistik (events-Tabelle). Kein externer Dienst, keine personenbezogenen Rohdaten — die Session-Kennung ist ein täglich rollender Hash.</p> <p style="margin:0 0 8px"><span class={`s-badge ${mail.provider === 'log' ? 'amber' : (mail.configured ? 'green' : 'red')}`}>{mail.provider === 'log' ? 'Log-Fallback' : mail.provider} {mail.provider !== 'log' ? (mail.configured ? '· konfiguriert' : '· unvollständig') : ''}</span></p>
<p class="s-help">Absender: <b>{mail.from}</b></p>
<p class="s-help" style="margin-top:6px">Bestellbestätigungen werden bei bezahlter Bestellung versendet. Ohne Provider-ENV landet die Mail im <a href={base + '/einstellungen/email-log'}>E-Mail-Log</a>.</p>
<p class="s-help" style="margin-top:6px">ENV: <b>MAIL_PROVIDER</b> (listmonk|smtp), <b>MAIL_FROM</b>, Listmonk-/SMTP-Variablen.</p>
</div> </div>
<div class="s-card s-card-pad"> <div class="s-card s-card-pad">
<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>. Nutzer & Rollen unter „Nutzer & Zugänge".</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>
</div> </div>
</form> </form>
<form method="POST" id="payForm"><input type="hidden" name="_action" value="payment" /><button class="s-btn s-btn-primary" type="submit">Zahlungsanbieter speichern</button></form>
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:6px;font-size:15px">Module (Feature-Flags)</div>
<p class="s-help" style="margin:0 0 14px">Schalte einzelne Funktionen zentral an oder aus. Abgeschaltete Module verschwinden aus Storefront und Admin.</p>
<form method="POST">
<input type="hidden" name="_action" value="features" />
<div class="s-form-grid" style="grid-template-columns:1fr 1fr;gap:14px">
{FEATURE_KEYS.map((k) => (
<label class="s-card" style="display:flex;gap:12px;align-items:flex-start;padding:14px;cursor:pointer">
<input type="checkbox" name={k} checked={feature(k)} style="margin-top:3px;width:18px;height:18px;accent-color:var(--accent)" />
<span><b style="display:block">{FEATURE_LABELS[k][0]}</b><span class="s-help" style="margin:0">{FEATURE_LABELS[k][1]}</span></span>
</label>
))}
</div>
<button class="s-btn s-btn-primary" type="submit" style="margin-top:14px">Module speichern</button>
</form>
</div>
</div> </div>
</Admin> </Admin>
+20 -1
View File
@@ -27,6 +27,10 @@ if (Astro.request.method === 'POST') {
featured: f.get('featured') === 'on', featured: f.get('featured') === 'on',
sort: parseInt(String(f.get('sort') || '99')) || 99, sort: parseInt(String(f.get('sort') || '99')) || 99,
desc: String(f.get('desc') || ''), desc: String(f.get('desc') || ''),
mwst: parseInt(String(f.get('mwst') || '19')) || 19,
base_amount: f.get('base_amount') === '' || f.get('base_amount') == null ? null : parseFloat(String(f.get('base_amount')).replace(',', '.')),
base_unit: String(f.get('base_unit') || ''),
base_price_per: String(f.get('base_price_per') || ''),
}; };
const _me = currentUser(Astro.request); const _me = currentUser(Astro.request);
if (isNew) { const newId = createProduct(data); recordAudit({ user: _me?.email, action: 'create', entity: 'product', entity_id: String(newId) }); return Astro.redirect(`${base}/produkte/${newId}?saved=1`); } if (isNew) { const newId = createProduct(data); recordAudit({ user: _me?.email, action: 'create', entity: 'product', entity_id: String(newId) }); return Astro.redirect(`${base}/produkte/${newId}?saved=1`); }
@@ -37,7 +41,7 @@ const product = isNew ? null : getProductById(id);
if (!isNew && !product) return Astro.redirect(base + '/produkte'); if (!isNew && !product) return Astro.redirect(base + '/produkte');
if (new URL(Astro.request.url).searchParams.get('saved')) flash = 'Produkt angelegt.'; if (new URL(Astro.request.url).searchParams.get('saved')) flash = 'Produkt angelegt.';
const cats = listCategories(); const cats = listCategories();
const p = product || { name: '', slug: '', shortName: '', priceCents: 0, category: '', sizes: ['One Size'], images: [], cardImage: '', badge: '', stock: '', material: '', features: [], featured: false, sort: 99, desc: '' }; const p = product || { name: '', slug: '', shortName: '', priceCents: 0, category: '', sizes: ['One Size'], images: [], cardImage: '', badge: '', stock: '', material: '', features: [], featured: false, sort: 99, desc: '', mwst: 19, base_amount: null, base_unit: '', base_price_per: '' };
const priceStr = product ? (product.priceCents / 100).toFixed(2).replace('.', ',') : ''; const priceStr = product ? (product.priceCents / 100).toFixed(2).replace('.', ',') : '';
--- ---
<Admin title={isNew ? 'Neues Produkt' : (product.shortName || product.name)} active="produkte" crumbs={[{ label: 'Produkte', href: base + '/produkte' }, { label: isNew ? 'Neu' : (product.shortName || product.name) }]}> <Admin title={isNew ? 'Neues Produkt' : (product.shortName || product.name)} active="produkte" crumbs={[{ label: 'Produkte', href: base + '/produkte' }, { label: isNew ? 'Neu' : (product.shortName || product.name) }]}>
@@ -77,6 +81,21 @@ const priceStr = product ? (product.priceCents / 100).toFixed(2).replace('.', ',
</div> </div>
<div class="s-card s-card-pad"> <div class="s-card s-card-pad">
<div class="s-field"><label class="s-label">Preis (€)</label><input class="s-input" name="price" value={priceStr} placeholder="0,00" required /></div> <div class="s-field"><label class="s-label">Preis (€)</label><input class="s-input" name="price" value={priceStr} placeholder="0,00" required /></div>
<div class="s-field"><label class="s-label">MwSt-Satz</label>
<select class="s-select" name="mwst">
<option value="19" selected={(p.mwst ?? 19) === 19}>19 % (Regelsatz)</option>
<option value="7" selected={p.mwst === 7}>7 % (ermäßigt)</option>
<option value="0" selected={p.mwst === 0}>0 % (steuerbefreit)</option>
</select>
</div>
<div class="s-field"><label class="s-label">Grundpreis (PAngV) — optional</label>
<div class="s-form-grid" style="grid-template-columns:1fr 1fr 1fr;gap:8px">
<input class="s-input" name="base_amount" value={p.base_amount ?? ''} placeholder="Menge z. B. 250" />
<input class="s-input" name="base_unit" value={p.base_unit || ''} placeholder="Einheit z. B. g" />
<input class="s-input" name="base_price_per" value={p.base_price_per || ''} placeholder="je z. B. kg" />
</div>
<div class="s-help">Beispiel: 250 g · je „kg" → zeigt „xx,xx €/kg". Unterstützt g/kg und ml/l-Umrechnung.</div>
</div>
<div class="s-field"><label class="s-label">Kategorie</label> <div class="s-field"><label class="s-label">Kategorie</label>
<input class="s-input" name="category" value={p.category} list="catlist" /> <input class="s-input" name="category" value={p.category} list="catlist" />
<datalist id="catlist">{cats.map((c) => (<option value={c} />))}</datalist> <datalist id="catlist">{cats.map((c) => (<option value={c} />))}</datalist>
+106
View File
@@ -0,0 +1,106 @@
---
import Admin from '../../../layouts/Admin.astro';
import { adminBase, currentUser } from '../../../lib/auth.js';
const base = adminBase();
import { listShippingZones, createShippingZone, updateShippingZone, deleteShippingZone, formatPrice, recordAudit } from '../../../lib/store.js';
let flash = '';
const _me = currentUser(Astro.request);
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData();
const action = String(f.get('_action') || '');
const editId = f.get('id') ? Number(f.get('id')) : null;
if (action === 'delete') {
deleteShippingZone(editId); recordAudit({ user: _me?.email, action: 'delete', entity: 'shipping_zone', entity_id: String(editId) });
flash = 'Zone gelöscht.';
} else if (action === 'zone') {
const data = {
name: String(f.get('name') || ''),
countries: String(f.get('countries') || ''),
price_cents: Math.round(parseFloat(String(f.get('price') || '0').replace(',', '.')) * 100) || 0,
free_over_cents: String(f.get('free_over') || '').trim() === '' ? null : Math.round(parseFloat(String(f.get('free_over')).replace(',', '.')) * 100),
delivery_days: String(f.get('delivery_days') || ''),
sort: parseInt(String(f.get('sort') || '99')) || 99,
active: f.get('active') === 'on',
};
if (editId) { updateShippingZone(editId, data); recordAudit({ user: _me?.email, action: 'update', entity: 'shipping_zone', entity_id: String(editId) }); flash = 'Zone gespeichert.'; }
else { const nid = createShippingZone(data); recordAudit({ user: _me?.email, action: 'create', entity: 'shipping_zone', entity_id: String(nid) }); flash = 'Zone angelegt.'; }
}
}
const zones = listShippingZones();
const euro = (c) => (c == null ? '' : (c / 100).toFixed(2).replace('.', ','));
---
<Admin title="Versand" active="versandzonen" crumbs={[{ label: 'Versand' }]}>
<div class="s-stack">
{flash && <div class="s-flash">✓ {flash}</div>}
<div class="s-card">
<div class="s-card-head">Versandzonen</div>
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Zone</th><th>Länder</th><th class="num">Preis</th><th class="num">Gratis ab</th><th>Lieferzeit</th><th>Status</th><th></th></tr></thead>
<tbody>
{zones.length === 0 ? (<tr><td colspan="7" class="s-empty">Keine Zonen</td></tr>) :
zones.map((z) => (
<tr>
<td><b>{z.name}</b></td>
<td class="s-muted">{z.countries}</td>
<td class="num">{formatPrice(z.price_cents)}</td>
<td class="num">{z.free_over_cents != null ? formatPrice(z.free_over_cents) : '—'}</td>
<td class="s-muted">{z.delivery_days || '—'}</td>
<td><span class={`s-badge ${z.active ? 'green' : 'gray'}`}>{z.active ? 'Aktiv' : 'Inaktiv'}</span></td>
<td style="text-align:right">
<button class="s-btn s-btn-sm" type="button" onclick={`editZone(${z.id})`}>Bearbeiten</button>
<form method="POST" style="display:inline" onsubmit="return confirm('Zone löschen?')"><input type="hidden" name="_action" value="delete" /><input type="hidden" name="id" value={z.id} /><button class="s-btn s-btn-sm s-btn-danger">Löschen</button></form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div class="s-card s-card-pad">
<div class="s-section-title" id="zoneFormTitle" style="margin-bottom:14px;font-size:15px">Neue Zone</div>
<form method="POST" id="zoneForm">
<input type="hidden" name="_action" value="zone" />
<input type="hidden" name="id" id="zoneId" value="" />
<div class="s-form-grid" style="grid-template-columns:1fr 1fr">
<div class="s-field"><label class="s-label">Name</label><input class="s-input" name="name" id="zName" required placeholder="z. B. Deutschland" /></div>
<div class="s-field"><label class="s-label">Länder (ISO, Komma; EU für alle EU)</label><input class="s-input" name="countries" id="zCountries" placeholder="DE oder AT,CH oder EU" /></div>
<div class="s-field"><label class="s-label">Preis (€)</label><input class="s-input" name="price" id="zPrice" placeholder="4,90" /></div>
<div class="s-field"><label class="s-label">Gratis ab (€, optional)</label><input class="s-input" name="free_over" id="zFree" placeholder="49,00" /></div>
<div class="s-field"><label class="s-label">Lieferzeit (Tage)</label><input class="s-input" name="delivery_days" id="zDays" placeholder="24" /></div>
<div class="s-field"><label class="s-label">Reihenfolge</label><input class="s-input" name="sort" id="zSort" type="number" value="99" /></div>
</div>
<label style="display:flex;gap:8px;align-items:center;margin:8px 0 14px"><input type="checkbox" name="active" id="zActive" checked style="width:18px;height:18px;accent-color:var(--accent)" /> Aktiv</label>
<button class="s-btn s-btn-primary" type="submit">Zone speichern</button>
<button class="s-btn" type="button" onclick="resetZone()" style="margin-left:8px">Neu</button>
</form>
</div>
</div>
<script is:inline define:vars={{ zones, euro: '' }} set:html={`window.__ZONES__=${JSON.stringify(zones)};`}></script>
<script is:inline>
function eu(c){ return c==null?'':(c/100).toFixed(2).replace('.',','); }
function editZone(id){
var z=(window.__ZONES__||[]).find(function(x){return x.id===id;}); if(!z) return;
document.getElementById('zoneId').value=z.id;
document.getElementById('zName').value=z.name||'';
document.getElementById('zCountries').value=z.countries||'';
document.getElementById('zPrice').value=eu(z.price_cents);
document.getElementById('zFree').value=z.free_over_cents!=null?eu(z.free_over_cents):'';
document.getElementById('zDays').value=z.delivery_days||'';
document.getElementById('zSort').value=z.sort||99;
document.getElementById('zActive').checked=!!z.active;
document.getElementById('zoneFormTitle').textContent='Zone bearbeiten';
document.getElementById('zoneForm').scrollIntoView({behavior:'smooth'});
}
function resetZone(){
document.getElementById('zoneForm').reset();
document.getElementById('zoneId').value='';
document.getElementById('zoneFormTitle').textContent='Neue Zone';
}
</script>
</Admin>
+63 -40
View File
@@ -1,6 +1,11 @@
import { createOrder, getSetting, validateDiscount, redeemDiscount, bestAutoDiscount } from '../../lib/store.js'; import {
createOrder, getSetting, validateDiscount, redeemDiscount, bestAutoDiscount,
shippingFor, taxFromGross, getProductBySlug, markOrderPaid, getOrderById, setOrderPayment,
} from '../../lib/store.js';
import { createPayment } from '../../lib/payments.js';
import { sendOrderConfirmation } from '../../lib/mailer.js';
export const prerender = false; export const prerender = false;
const keyLooksReal = (k) => typeof k === 'string' && /^sk_(test|live)_[A-Za-z0-9]{16,}/.test(k.trim());
function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); } function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); }
export async function POST({ request }) { export async function POST({ request }) {
@@ -10,74 +15,92 @@ export async function POST({ request }) {
const contact = body.contact || {}; const contact = body.contact || {};
if (!items.length) return json({ error: 'Warenkorb leer' }, 400); if (!items.length) return json({ error: 'Warenkorb leer' }, 400);
const lineItems = items.map((i) => ({ const lineItems = items.map((i) => {
const prod = i.slug ? getProductBySlug(i.slug) : null;
return {
slug: i.slug || (prod && prod.slug) || '',
name: i.name, size: i.size || '', qty: Math.max(1, parseInt(i.qty) || 1), name: i.name, size: i.size || '', qty: Math.max(1, parseInt(i.qty) || 1),
priceCents: Math.round(Number(i.priceCents) || Number(i.price) * 100 || 0), image: i.image || '', priceCents: Math.round(Number(i.priceCents) || Number(i.price) * 100 || 0), image: i.image || '',
})); mwst: prod ? Number(prod.mwst) || 0 : 19,
};
});
const subtotal = lineItems.reduce((s, i) => s + i.priceCents * i.qty, 0); const subtotal = lineItems.reduce((s, i) => s + i.priceCents * i.qty, 0);
const freeShip = Number(getSetting('free_shipping_cents', '4900')) || 4900;
let shipping = subtotal >= freeShip ? 0 : 490;
const customer_name = [contact.vorname, contact.nachname].filter(Boolean).join(' ').trim(); const customer_name = [contact.vorname, contact.nachname].filter(Boolean).join(' ').trim();
const email = contact.email || ''; const email = contact.email || '';
// Land → ISO-Code (Storefront liefert Code direkt via country, ältere Forms „Deutschland")
const countryRaw = String(body.country || contact.country || contact.land || 'DE').trim();
const COUNTRY_MAP = { 'deutschland': 'DE', 'österreich': 'AT', 'oesterreich': 'AT', 'schweiz': 'CH' };
let country = countryRaw.length === 2 ? countryRaw.toUpperCase() : (COUNTRY_MAP[countryRaw.toLowerCase()] || 'DE');
// Rabattcode serverseitig erneut validieren (niemals dem Client vertrauen). // Rabattcode serverseitig erneut validieren (niemals dem Client vertrauen).
let discount = null; // { id, code, amountCents, freeShipping } let discount = null;
const rawCode = String(body.code || '').trim(); const rawCode = String(body.code || '').trim();
if (rawCode) { if (rawCode) {
const v = validateDiscount(rawCode, subtotal, email || undefined); const v = validateDiscount(rawCode, subtotal, email || undefined);
if (v.ok) discount = v; if (v.ok) discount = v;
} }
// Kein (gültiger) Code? Bestes automatisches Discount anwenden, falls Bedingungen erfüllt.
if (!discount) { if (!discount) {
const auto = bestAutoDiscount(subtotal); const auto = bestAutoDiscount(subtotal);
if (auto && auto.ok) discount = auto; if (auto && auto.ok) discount = auto;
} }
let discountCents = 0; let discountCents = 0;
let discountFreeShipping = false;
if (discount) { if (discount) {
if (discount.freeShipping) { shipping = 0; } if (discount.freeShipping) discountFreeShipping = true;
else { discountCents = Math.min(discount.amountCents, subtotal); } else discountCents = Math.min(discount.amountCents, subtotal);
} }
const total = Math.max(0, subtotal - discountCents + shipping);
// Versand serverseitig aus Versandzonen berechnen.
const ship = shippingFor(country, subtotal);
let shippingCents = ship.ok ? ship.price_cents : 0;
if (discountFreeShipping) shippingCents = 0;
const total = Math.max(0, subtotal - discountCents + shippingCents);
// Enthaltene MwSt aus Brutto (Positionen + Versand) herausrechnen, nach Satz gruppiert.
// Rabatt mindert anteilig die Nettobasis → vereinfacht: MwSt aus dem rabattierten Brutto.
const taxByRate = {};
const discountFactor = subtotal > 0 ? (subtotal - discountCents) / subtotal : 1;
for (const i of lineItems) {
const grossLine = Math.round(i.priceCents * i.qty * discountFactor);
const t = taxFromGross(grossLine, i.mwst);
taxByRate[i.mwst] = (taxByRate[i.mwst] || 0) + t;
}
// Versand mit dem höchsten vorkommenden Satz (i. d. R. 19 %) belegen.
const shipRate = lineItems.length ? Math.max(...lineItems.map(i => i.mwst || 0)) : 19;
if (shippingCents > 0) taxByRate[shipRate] = (taxByRate[shipRate] || 0) + taxFromGross(shippingCents, shipRate);
const taxCents = Object.values(taxByRate).reduce((a, b) => a + b, 0);
const order = await createOrder({ const order = await createOrder({
email, customer_name, items: lineItems, total_cents: total, status: 'pending', email, customer_name, items: lineItems, total_cents: total, status: 'pending',
address: [contact.strasse, contact.plz, contact.ort, contact.land].filter(Boolean).join(', '), address: [contact.strasse, contact.plz, contact.ort, country].filter(Boolean).join(', '),
discount_code: discount ? discount.code : '', discount_cents: discountCents, discount_code: discount ? discount.code : '', discount_cents: discountCents,
tax_cents: taxCents, shipping_cents: shippingCents, country,
}); });
// Einlösung verbuchen (used_count + Redemption).
if (discount) { if (discount) {
redeemDiscount(discount.id, discount.code, email, order.id, discount.freeShipping ? 0 : discountCents); redeemDiscount(discount.id, discount.code, email, order.id, discount.freeShipping ? 0 : discountCents);
} }
const secret = process.env.STRIPE_SECRET_KEY || '';
const origin = new URL(request.url).origin; const origin = new URL(request.url).origin;
const returnUrl = `${origin}/bestellung-erfolgreich?order=${order.number}`;
const pay = await createPayment({
order, items: lineItems, lineItems, totalCents: total, shippingCents,
discountCents, discountName: discount ? discount.code : 'Rabatt',
returnUrl, demoUrl: `${returnUrl}&demo=1`, cancelUrl: `${origin}/warenkorb`,
webhookUrl: `${origin}/api/payments/webhook`, email,
description: `Bestellung ${order.number}`,
});
if (keyLooksReal(secret)) { // Demo-Provider: Order sofort als bezahlt markieren + Bestätigungsmail auslösen.
try { if (pay.provider === 'demo') {
const Stripe = (await import('stripe')).default; const res = markOrderPaid(order.id, { payment_provider: 'demo', payment_id: '' });
const stripe = new Stripe(secret); if (res.changed) { try { await sendOrderConfirmation(res.order); } catch {} }
const sessionCfg = { } else if (pay.paymentId) {
mode: 'payment', payment_method_types: ['card'], locale: 'de', // Payment-ID an der Order vermerken, damit der Webhook sie zuordnen kann.
customer_email: email || undefined, setOrderPayment(order.id, { payment_id: pay.paymentId, payment_provider: pay.provider });
line_items: [
...lineItems.map((i) => ({ quantity: i.qty, price_data: { currency: 'eur', unit_amount: i.priceCents,
product_data: { name: `${i.name}${i.size ? ' · ' + i.size : ''}` } } })),
...(shipping > 0 ? [{ quantity: 1, price_data: { currency: 'eur', unit_amount: shipping, product_data: { name: 'Versand (DE)' } } }] : []),
],
success_url: `${origin}/bestellung-erfolgreich?order=${order.number}`,
cancel_url: `${origin}/warenkorb`, metadata: { order_number: order.number },
};
// Warenkorb-Rabatt als Stripe-Coupon (amount_off) anhängen.
if (discountCents > 0) {
try {
const coupon = await stripe.coupons.create({ amount_off: discountCents, currency: 'eur', duration: 'once', name: discount.code });
sessionCfg.discounts = [{ coupon: coupon.id }];
} catch {}
} }
const session = await stripe.checkout.sessions.create(sessionCfg);
return json({ url: session.url }); return json({ url: pay.redirectUrl });
} catch (e) { return json({ url: `/bestellung-erfolgreich?order=${order.number}&demo=1` }); }
}
return json({ url: `/bestellung-erfolgreich?order=${order.number}&demo=1` });
} }
+42
View File
@@ -0,0 +1,42 @@
import { getOrderByPaymentId, markOrderPaid } from '../../../lib/store.js';
import { molliePaymentStatus } from '../../../lib/payments.js';
import { sendOrderConfirmation } from '../../../lib/mailer.js';
export const prerender = false;
// Mollie sendet POST mit `id` (Payment-ID). Status via API prüfen → bei paid Order setzen + Mail.
export async function POST({ request }) {
let paymentId = '';
try {
const ct = request.headers.get('content-type') || '';
if (ct.includes('application/json')) {
const b = await request.json().catch(() => ({}));
paymentId = b.id || '';
} else {
const form = await request.formData().catch(() => null);
if (form) paymentId = String(form.get('id') || '');
}
} catch {}
if (!paymentId) return new Response('OK', { status: 200 });
try {
const st = await molliePaymentStatus(paymentId);
if (st.status === 'paid') {
let order = getOrderByPaymentId(paymentId);
if (!order && st.metadata && st.metadata.order_id) {
const { getOrderById } = await import('../../../lib/store.js');
order = getOrderById(st.metadata.order_id);
}
if (order) {
const res = markOrderPaid(order.id, { payment_id: paymentId, payment_provider: 'mollie' });
if (res.changed) { try { await sendOrderConfirmation(res.order); } catch {} }
}
}
} catch (e) {
// Webhook nie mit 500 quittieren (Mollie würde sonst dauerhaft retrien); 200 mit Log.
console.error('[payments:webhook]', e && e.message || e);
}
return new Response('OK', { status: 200 });
}
// GET zur einfachen Erreichbarkeitsprüfung.
export async function GET() { return new Response('payments webhook ready', { status: 200 }); }
+57
View File
@@ -0,0 +1,57 @@
import { shippingFor, taxFromGross, getProductBySlug, validateDiscount, bestAutoDiscount, listActiveShippingZones } 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' } }); }
// Berechnet Versand + enthaltene MwSt serverseitig für eine Länder-/Warenkorb-Kombination.
export async function POST({ request }) {
let body;
try { body = await request.json(); } catch { return json({ error: 'Bad request' }, 400); }
const items = Array.isArray(body.items) ? body.items : [];
const country = String(body.country || 'DE').toUpperCase().trim();
const lineItems = items.map((i) => {
const prod = i.slug ? getProductBySlug(i.slug) : null;
return {
qty: Math.max(1, parseInt(i.qty) || 1),
priceCents: Math.round(Number(i.priceCents) || Number(i.price) * 100 || 0),
mwst: prod ? Number(prod.mwst) || 0 : 19,
};
});
const subtotal = lineItems.reduce((s, i) => s + i.priceCents * i.qty, 0);
// Rabatt (Code oder Auto) für die Anzeige berücksichtigen.
let discount = null;
const rawCode = String(body.code || '').trim();
if (rawCode) { const v = validateDiscount(rawCode, subtotal, body.email || undefined); if (v.ok) discount = v; }
if (!discount) { const a = bestAutoDiscount(subtotal); if (a && a.ok) discount = a; }
let discountCents = 0, freeShipping = false;
if (discount) { if (discount.freeShipping) freeShipping = true; else discountCents = Math.min(discount.amountCents, subtotal); }
const ship = shippingFor(country, subtotal);
let shippingCents = ship.ok ? ship.price_cents : 0;
if (freeShipping) shippingCents = 0;
const taxByRate = {};
const discountFactor = subtotal > 0 ? (subtotal - discountCents) / subtotal : 1;
for (const i of lineItems) {
const grossLine = Math.round(i.priceCents * i.qty * discountFactor);
taxByRate[i.mwst] = (taxByRate[i.mwst] || 0) + taxFromGross(grossLine, i.mwst);
}
const shipRate = lineItems.length ? Math.max(...lineItems.map(i => i.mwst || 0)) : 19;
if (shippingCents > 0) taxByRate[shipRate] = (taxByRate[shipRate] || 0) + taxFromGross(shippingCents, shipRate);
const taxCents = Object.values(taxByRate).reduce((a, b) => a + b, 0);
const total = Math.max(0, subtotal - discountCents + shippingCents);
return json({
ok: ship.ok, country, subtotal, shippingCents, taxCents,
taxByRate: Object.entries(taxByRate).map(([rate, cents]) => ({ rate: Number(rate), cents })).filter(t => t.cents > 0),
discountCents, freeShipping, total,
zone_name: ship.zone_name || '', delivery_days: ship.delivery_days || '',
free_over_cents: ship.free_over_cents || null,
});
}
// Aktive Versandzonen für die Länder-Auswahl im Checkout.
export async function GET() {
const zones = listActiveShippingZones();
return json({ zones: zones.map(z => ({ id: z.id, name: z.name, countries: z.countries, delivery_days: z.delivery_days })) });
}
+3 -1
View File
@@ -1,8 +1,10 @@
import { addSubscriber } from '../../lib/store.js'; import { addSubscriber, feature } from '../../lib/store.js';
export const prerender = false; export const prerender = false;
function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); } function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); }
export async function POST({ request }) { export async function POST({ request }) {
// Feature-Flag: Newsletter abschaltbar.
if (!feature('feature_newsletter')) return json({ ok: false, error: 'Newsletter deaktiviert' }, 403);
let b; let b;
try { b = await request.json(); } catch { return json({ ok: false }, 400); } try { b = await request.json(); } catch { return json({ ok: false }, 400); }
const email = (b.email || '').trim(); const email = (b.email || '').trim();
+21 -4
View File
@@ -1,20 +1,37 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import { getOrderByNumber, formatPrice } from '../lib/store.js';
const url = new URL(Astro.request.url); const url = new URL(Astro.request.url);
const order = url.searchParams.get('order') || ''; const orderNum = url.searchParams.get('order') || '';
const demo = url.searchParams.get('demo') === '1'; const demo = url.searchParams.get('demo') === '1';
const order = orderNum ? getOrderByNumber(orderNum) : null;
const subtotal = order ? (order.total_cents + (order.discount_cents || 0) - (order.shipping_cents || 0)) : 0;
--- ---
<Base title="Bestellung erfolgreich"> <Base title="Bestellung erfolgreich">
<div class="wrap"> <div class="wrap">
<div class="empty-state" style="padding:90px 20px"> <div class="empty-state" style="padding:70px 20px 40px">
<div style="width:72px;height:72px;border-radius:50%;background:color-mix(in srgb,var(--accent) 16%,white);display:grid;place-items:center;margin:0 auto 20px"> <div style="width:72px;height:72px;border-radius:50%;background:color-mix(in srgb,var(--accent) 16%,white);display:grid;place-items:center;margin:0 auto 20px">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg> <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</div> </div>
<h1>Vielen Dank für deine Bestellung!</h1> <h1>Vielen Dank für deine Bestellung!</h1>
{order && <p style="font-size:18px;margin-top:10px">Deine Bestellnummer: <b style="color:var(--ink)">{order}</b></p>} {orderNum && <p style="font-size:18px;margin-top:10px">Deine Bestellnummer: <b style="color:var(--ink)">{orderNum}</b></p>}
<p style="max-width:46ch;margin:10px auto 0">Wir haben deine Bestellung erhalten und melden uns per E-Mail mit den Versanddetails.</p> <p style="max-width:46ch;margin:10px auto 0">Wir haben deine Bestellung erhalten und melden uns per E-Mail mit den Versanddetails.</p>
{demo && <p style="font-size:13px;color:var(--faint);margin-top:14px">Demo-Hinweis: Diese Bestellung wurde ohne echte Zahlung im Demo-Modus angelegt.</p>} {demo && <p style="font-size:13px;color:var(--faint);margin-top:14px">Demo-Hinweis: Diese Bestellung wurde ohne echte Zahlung im Demo-Modus angelegt.</p>}
<a class="btn btn-primary btn-lg" href="/shop" style="margin-top:24px">Weiter einkaufen</a>
</div> </div>
{order && (
<div class="summary" style="max-width:440px;margin:0 auto 60px">
{order.items.map((i) => (
<div class="sum-row"><span>{i.qty}× {i.name}{i.size && i.size !== 'One Size' ? ` (${i.size})` : ''}</span><span>{formatPrice(i.priceCents * i.qty)}</span></div>
))}
<div class="sum-row"><span>Zwischensumme</span><span>{formatPrice(subtotal)}</span></div>
{order.discount_cents > 0 && (<div class="sum-row" style="color:var(--accent)"><span>Rabatt{order.discount_code ? ` (${order.discount_code})` : ''}</span><span>{formatPrice(order.discount_cents)}</span></div>)}
<div class="sum-row"><span>Versand</span><span>{order.shipping_cents === 0 ? 'Kostenlos' : formatPrice(order.shipping_cents)}</span></div>
<div class="sum-row total"><span>Gesamt</span><span>{formatPrice(order.total_cents)}</span></div>
{order.tax_cents > 0 && (<div class="sum-row" style="font-size:12px;color:var(--faint)"><span>inkl. MwSt.</span><span>{formatPrice(order.tax_cents)}</span></div>)}
</div>
)}
<div style="text-align:center;padding-bottom:60px"><a class="btn btn-primary btn-lg" href="/shop">Weiter einkaufen</a></div>
</div> </div>
</Base> </Base>
+66 -27
View File
@@ -1,14 +1,29 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import { getSetting } from '../lib/store.js'; import { getSetting, listActiveShippingZones, resolvePaymentProvider } from '../lib/store.js';
const freeShip = Number(getSetting('free_shipping_cents', '4900')) || 4900;
const currency = getSetting('currency', 'EUR'); const currency = getSetting('currency', 'EUR');
const hasStripe = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test((process.env.STRIPE_SECRET_KEY || '').trim()); const zones = listActiveShippingZones();
// Länder-Optionen aus aktiven Zonen ableiten (ISO-Codes + Klartext).
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 countrySet = [];
const seen = new Set();
for (const z of zones) {
for (const raw of String(z.countries || '').split(',').map(c => c.trim().toUpperCase()).filter(Boolean)) {
if (raw === 'EU' || raw === '*') continue;
if (!seen.has(raw)) { seen.add(raw); countrySet.push({ code: raw, name: COUNTRY_NAMES[raw] || raw }); }
}
}
// Sicherstellen, dass DE als Standard zuerst steht.
if (!seen.has('DE')) countrySet.unshift({ code: 'DE', name: 'Deutschland' });
countrySet.sort((a, b) => (a.code === 'DE' ? -1 : b.code === 'DE' ? 1 : a.name.localeCompare(b.name)));
const pp = resolvePaymentProvider();
const providerLabel = { mollie: 'Mollie', stripe: 'Stripe', demo: 'Demo' }[pp.provider] || pp.provider;
const demoMode = pp.provider === 'demo';
--- ---
<Base title="Kasse"> <Base title="Kasse">
<div class="wrap"> <div class="wrap">
<h1 style="padding:40px 0 8px">Zur Kasse</h1> <h1 style="padding:40px 0 8px">Zur Kasse</h1>
{!hasStripe && (<p style="color:var(--faint);font-size:14px">Demo-Modus: Es ist kein echter Stripe-Schlüssel hinterlegt — die Bestellung wird ohne Zahlung abgeschlossen.</p>)} {demoMode && (<p style="color:var(--faint);font-size:14px">Demo-Modus: Kein echter Zahlungsanbieter konfiguriert — die Bestellung wird ohne Zahlung abgeschlossen.</p>)}
<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>
@@ -19,9 +34,13 @@ const hasStripe = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test((process.env.STRIPE_SE
<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" required /></div>
<div class="field"><label>PLZ</label><input name="plz" 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"><label>Ort</label><input name="ort" required /></div>
<div class="field full"><label>Land</label><input name="land" value="Deutschland" /></div> <div class="field full"><label>Land</label>
<select name="country" id="coCountry">
{countrySet.map((c) => (<option value={c.code} selected={c.code === 'DE'}>{c.name}</option>))}
</select>
</div> </div>
<button class="btn btn-primary btn-lg btn-block" type="submit" id="coBtn" style="margin-top:8px">Kostenpflichtig bestellen</button> </div>
<button class="btn btn-primary btn-lg btn-block" type="submit" id="coBtn" style="margin-top:8px">Zahlungspflichtig bestellen</button>
<div id="coMsg" style="margin-top:12px;color:var(--accent);font-size:14px"></div> <div id="coMsg" style="margin-top:12px;color:var(--accent);font-size:14px"></div>
</form> </form>
<div class="summary"> <div class="summary">
@@ -38,46 +57,66 @@ const hasStripe = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test((process.env.STRIPE_SE
</div> </div>
</div> </div>
<script is:inline define:vars={{ freeShip, currency }}> <script is:inline define:vars={{ currency }}>
(function () { (function () {
var discount = null; // { code, amountCents, freeShipping, label } var code = ''; // aktiver Gutscheincode
var quote = null; // letztes Server-Quote { shippingCents, taxCents, taxByRate, discountCents, freeShipping, total, ... }
function fmt(c) { try { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency }).format((c||0)/100); } catch(e){ return ((c||0)/100).toFixed(2)+' '+currency; } } function fmt(c) { try { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: currency }).format((c||0)/100); } catch(e){ return ((c||0)/100).toFixed(2)+' '+currency; } }
function summary() { function country() { var el = document.getElementById('coCountry'); return el ? el.value : 'DE'; }
function render() {
var items = window.HDC.read(), sub = window.HDC.subtotal(); var items = window.HDC.read(), sub = window.HDC.subtotal();
var box = document.getElementById('coSummary'); var box = document.getElementById('coSummary');
if (!items.length) { box.innerHTML = '<p>Dein Warenkorb ist leer. <a class="s-link" href="/shop">Zum Shop</a></p>'; return; } if (!items.length) { box.innerHTML = '<p>Dein Warenkorb ist leer. <a class="s-link" href="/shop">Zum Shop</a></p>'; return; }
var freeByDisc = discount && discount.freeShipping;
var ship = (freeByDisc || sub >= freeShip) ? 0 : 490;
var discCents = (discount && !discount.freeShipping) ? Math.min(discount.amountCents, sub) : 0;
var total = Math.max(0, sub - discCents + ship);
var html = items.map(function (i) { return '<div class="sum-row"><span>' + i.qty + '× ' + i.name + (i.size && i.size!=='One Size' ? ' ('+i.size+')':'') + '</span><span>' + fmt(i.priceCents*i.qty) + '</span></div>'; }).join(''); var html = items.map(function (i) { return '<div class="sum-row"><span>' + i.qty + '× ' + i.name + (i.size && i.size!=='One Size' ? ' ('+i.size+')':'') + '</span><span>' + fmt(i.priceCents*i.qty) + '</span></div>'; }).join('');
html += '<div class="sum-row"><span>Zwischensumme</span><span>' + fmt(sub) + '</span></div>'; html += '<div class="sum-row"><span>Zwischensumme</span><span>' + fmt(sub) + '</span></div>';
if (discount) { var ship = quote ? quote.shippingCents : null;
if (discount.freeShipping) html += '<div class="sum-row" style="color:var(--accent)"><span>Gutschein ' + discount.code + '</span><span>Gratisversand</span></div>'; var discCents = quote ? quote.discountCents : 0;
else html += '<div class="sum-row" style="color:var(--accent)"><span>Gutschein ' + discount.code + '</span><span>' + fmt(discCents) + '</span></div>'; var freeShip = quote && quote.freeShipping;
} if (discCents > 0) html += '<div class="sum-row" style="color:var(--accent)"><span>Gutschein ' + code + '</span><span>' + fmt(discCents) + '</span></div>';
html += '<div class="sum-row"><span>Versand</span><span>' + (ship===0?'Kostenlos':fmt(ship)) + '</span></div>'; else if (freeShip) html += '<div class="sum-row" style="color:var(--accent)"><span>Gutschein ' + code + '</span><span>Gratisversand</span></div>';
html += '<div class="sum-row"><span>Versand' + (quote && quote.delivery_days ? ' <span style="color:var(--faint);font-size:12px">(' + quote.delivery_days + ' Tage)</span>' : '') + '</span><span>' + (ship === null ? '…' : (ship === 0 ? 'Kostenlos' : fmt(ship))) + '</span></div>';
var total = quote ? quote.total : sub;
html += '<div class="sum-row total"><span>Gesamt</span><span>' + fmt(total) + '</span></div>'; html += '<div class="sum-row total"><span>Gesamt</span><span>' + fmt(total) + '</span></div>';
if (quote && quote.taxByRate && quote.taxByRate.length) {
html += quote.taxByRate.map(function (t) { return '<div class="sum-row" style="font-size:12px;color:var(--faint)"><span>inkl. ' + t.rate + '% MwSt.</span><span>' + fmt(t.cents) + '</span></div>'; }).join('');
} else if (quote && quote.taxCents) {
html += '<div class="sum-row" style="font-size:12px;color:var(--faint)"><span>inkl. MwSt.</span><span>' + fmt(quote.taxCents) + '</span></div>';
}
html += '<div class="sum-row" style="font-size:12px;color:var(--faint)"><span>inkl. MwSt., zzgl. Versand je nach Land</span><span></span></div>';
box.innerHTML = html; box.innerHTML = html;
} }
function refreshQuote() {
var items = window.HDC.read();
if (!items.length) { quote = null; render(); return; }
var email = (document.querySelector('[name=email]') || {}).value || '';
fetch('/api/shipping-quote', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ items: items, country: country(), code: code, email: email }) })
.then(function(r){ return r.json(); })
.then(function(d){ quote = d; render(); })
.catch(function(){ quote = null; render(); });
}
function applyCode() { function applyCode() {
var input = document.getElementById('discCode'), msg = document.getElementById('discMsg'); var input = document.getElementById('discCode'), msg = document.getElementById('discMsg');
var code = (input.value || '').trim(); var c = (input.value || '').trim();
if (!code) { discount = null; msg.textContent=''; summary(); return; } if (!c) { code = ''; msg.textContent=''; refreshQuote(); return; }
var items = window.HDC.read(); var items = window.HDC.read();
var email = (document.querySelector('[name=email]') || {}).value || ''; var email = (document.querySelector('[name=email]') || {}).value || '';
msg.style.color = 'var(--faint)'; msg.textContent = 'Prüfe …'; msg.style.color = 'var(--faint)'; msg.textContent = 'Prüfe …';
fetch('/api/discount', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ code: code, items: items, email: email }) }) fetch('/api/discount', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ code: c, items: items, email: email }) })
.then(function(r){ return r.json(); }) .then(function(r){ return r.json(); })
.then(function(d){ .then(function(d){
if (d.ok) { discount = d; msg.style.color='var(--accent)'; msg.textContent = 'Code „' + d.code + '" angewendet — ' + d.label + '.'; } if (d.ok) { code = d.code; msg.style.color='var(--accent)'; msg.textContent = 'Code „' + d.code + '" angewendet — ' + d.label + '.'; }
else { discount = null; msg.style.color='#b3261e'; msg.textContent = d.reason || 'Code ungültig'; } else { code = ''; msg.style.color='#b3261e'; msg.textContent = d.reason || 'Code ungültig'; }
summary(); refreshQuote();
}) })
.catch(function(){ msg.style.color='#b3261e'; msg.textContent='Bitte später erneut versuchen.'; }); .catch(function(){ msg.style.color='#b3261e'; msg.textContent='Bitte später erneut versuchen.'; });
} }
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
summary(); render(); refreshQuote();
document.getElementById('coCountry').addEventListener('change', refreshQuote);
document.getElementById('discApply').addEventListener('click', applyCode); document.getElementById('discApply').addEventListener('click', applyCode);
document.getElementById('discCode').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); applyCode(); } }); document.getElementById('discCode').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); applyCode(); } });
var f = document.getElementById('coForm'); var f = document.getElementById('coForm');
@@ -88,10 +127,10 @@ const hasStripe = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test((process.env.STRIPE_SE
window.HDC.track('checkout_start', window.HDC.subtotal()); window.HDC.track('checkout_start', window.HDC.subtotal());
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: discount ? discount.code : '' }) }) fetch('/api/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: items, contact: contact, code: code, country: country() }) })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (d) { if (d.url) { window.HDC.clear(); window.location.href = d.url; } else { throw new Error(d.error || 'Fehler'); } }) .then(function (d) { if (d.url) { window.HDC.clear(); window.location.href = d.url; } else { throw new Error(d.error || 'Fehler'); } })
.catch(function (err) { document.getElementById('coMsg').textContent = err.message; btn.disabled = false; btn.textContent = 'Kostenpflichtig bestellen'; }); .catch(function (err) { document.getElementById('coMsg').textContent = err.message; btn.disabled = false; btn.textContent = 'Zahlungspflichtig bestellen'; });
}); });
}); });
})(); })();
+2 -1
View File
@@ -1,6 +1,6 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import { listActiveSlides, listFeatured, listCategories, listProducts, getSettings, formatPrice } from '../lib/store.js'; import { listActiveSlides, listFeatured, listCategories, listProducts, getSettings, formatPrice, basePriceLabel } from '../lib/store.js';
const settings = getSettings(); const settings = getSettings();
const slides = listActiveSlides(); const slides = listActiveSlides();
@@ -83,6 +83,7 @@ const tagline = settings.shop_tagline || '';
<span class="prod-cat">{p.category}</span> <span class="prod-cat">{p.category}</span>
<span class="prod-name">{p.shortName || p.name}</span> <span class="prod-name">{p.shortName || p.name}</span>
<span class="prod-price">{formatPrice(p.priceCents)}</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> </div>
</a> </a>
))} ))}
+8 -1
View File
@@ -1,6 +1,6 @@
--- ---
import Base from '../../layouts/Base.astro'; import Base from '../../layouts/Base.astro';
import { getProductBySlug, listProducts, formatPrice } from '../../lib/store.js'; import { getProductBySlug, listProducts, formatPrice, basePriceLabel } from '../../lib/store.js';
const { slug } = Astro.params; const { slug } = Astro.params;
const product = getProductBySlug(slug); const product = getProductBySlug(slug);
@@ -9,6 +9,8 @@ if (!product) return Astro.redirect('/shop');
const related = listProducts().filter(p => p.category === product.category && p.slug !== product.slug).slice(0, 4); const related = listProducts().filter(p => p.category === product.category && p.slug !== product.slug).slice(0, 4);
const gallery = product.images && product.images.length ? product.images : (product.cardImage ? [product.cardImage] : []); const gallery = product.images && product.images.length ? product.images : (product.cardImage ? [product.cardImage] : []);
const addData = { slug: product.slug, name: product.name, priceCents: product.priceCents, image: product.cardImage || gallery[0] || '', sizes: product.sizes }; 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 basePrice = basePriceLabel(product.priceCents, product);
--- ---
<Base title={product.shortName || product.name} description={product.desc}> <Base title={product.shortName || product.name} description={product.desc}>
<div class="wrap"> <div class="wrap">
@@ -27,6 +29,10 @@ const addData = { slug: product.slug, name: product.name, priceCents: product.pr
<div class="pdp-cat">{product.category}</div> <div class="pdp-cat">{product.category}</div>
<h1>{product.name}</h1> <h1>{product.name}</h1>
<div class="pdp-price">{formatPrice(product.priceCents)}</div> <div class="pdp-price">{formatPrice(product.priceCents)}</div>
<div class="pdp-price-meta" style="margin:-6px 0 14px;font-size:13px;color:var(--faint)">
<span>inkl. {mwst}% MwSt., zzgl. <a href="/seite/agb" style="color:inherit;text-decoration:underline">Versand</a></span>
{basePrice && <span style="display:block;margin-top:2px">{basePrice}</span>}
</div>
{product.desc && <p class="pdp-desc">{product.desc}</p>} {product.desc && <p class="pdp-desc">{product.desc}</p>}
{product.sizes && product.sizes.length > 0 && product.sizes[0] !== 'One Size' && ( {product.sizes && product.sizes.length > 0 && product.sizes[0] !== 'One Size' && (
@@ -65,6 +71,7 @@ const addData = { slug: product.slug, name: product.name, priceCents: product.pr
<span class="prod-cat">{p.category}</span> <span class="prod-cat">{p.category}</span>
<span class="prod-name">{p.shortName || p.name}</span> <span class="prod-name">{p.shortName || p.name}</span>
<span class="prod-price">{formatPrice(p.priceCents)}</span> <span class="prod-price">{formatPrice(p.priceCents)}</span>
<span class="prod-tax">inkl. MwSt.</span>
</div> </div>
</a> </a>
))} ))}
+2 -1
View File
@@ -1,6 +1,6 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import { listProducts, listCategories, formatPrice } from '../lib/store.js'; import { listProducts, listCategories, formatPrice, basePriceLabel } from '../lib/store.js';
const products = listProducts(); const products = listProducts();
const categories = listCategories(); const categories = listCategories();
@@ -39,6 +39,7 @@ const filtered = activeCat ? products.filter(p => p.category === activeCat) : pr
<span class="prod-name">{p.shortName || p.name}</span> <span class="prod-name">{p.shortName || p.name}</span>
{p.stock === 0 && <span class="soldout">Ausverkauft</span>} {p.stock === 0 && <span class="soldout">Ausverkauft</span>}
<span class="prod-price">{formatPrice(p.priceCents)}</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> </div>
</a> </a>
))} ))}
+1
View File
@@ -36,6 +36,7 @@ const currency = getSetting('currency', 'EUR');
'<div class="sum-row"><span>Versand</span><span>' + (ship === 0 ? 'Kostenlos' : fmt(ship)) + '</span></div>' + '<div class="sum-row"><span>Versand</span><span>' + (ship === 0 ? 'Kostenlos' : fmt(ship)) + '</span></div>' +
(ship > 0 ? '<div class="sum-row" style="font-size:13px;color:var(--accent)"><span>Noch ' + fmt(freeShip - sub) + ' bis Gratis-Versand</span><span></span></div>' : '') + (ship > 0 ? '<div class="sum-row" style="font-size:13px;color:var(--accent)"><span>Noch ' + fmt(freeShip - sub) + ' bis Gratis-Versand</span><span></span></div>' : '') +
'<div class="sum-row total"><span>Gesamt</span><span>' + fmt(sub + ship) + '</span></div>' + '<div class="sum-row total"><span>Gesamt</span><span>' + fmt(sub + ship) + '</span></div>' +
'<div class="sum-row" style="font-size:12px;color:var(--faint)"><span>inkl. MwSt., zzgl. Versand</span><span></span></div>' +
'<a class="btn btn-primary btn-lg btn-block" href="/checkout" style="margin-top:16px">Zur Kasse</a>' + '<a class="btn btn-primary btn-lg btn-block" href="/checkout" style="margin-top:16px">Zur Kasse</a>' +
'<a class="btn btn-ghost btn-block" href="/shop" style="margin-top:10px">Weiter einkaufen</a></div></div>'; '<a class="btn btn-ghost btn-block" href="/shop" style="margin-top:10px">Weiter einkaufen</a></div></div>';
root.querySelectorAll('[data-inc]').forEach(function (b) { b.onclick = function () { var i = +b.getAttribute('data-inc'); window.HDC.setQty(i, window.HDC.read()[i].qty + 1); render(); }; }); root.querySelectorAll('[data-inc]').forEach(function (b) { b.onclick = function () { var i = +b.getAttribute('data-inc'); window.HDC.setQty(i, window.HDC.read()[i].qty + 1); render(); }; });
+1
View File
@@ -93,6 +93,7 @@ p{margin:0 0 1rem}
.prod-cat{font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--faint)} .prod-cat{font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--faint)}
.prod-name{font-family:var(--serif);font-size:18px;color:var(--ink);line-height:1.2} .prod-name{font-family:var(--serif);font-size:18px;color:var(--ink);line-height:1.2}
.prod-price{margin-top:auto;font-size:17px;font-weight:700;color:var(--ink);padding-top:8px} .prod-price{margin-top:auto;font-size:17px;font-weight:700;color:var(--ink);padding-top:8px}
.prod-tax{font-size:11.5px;color:var(--faint,#8a8580);margin-top:2px;line-height:1.3}
.soldout{font-size:12px;color:var(--accent);font-weight:600} .soldout{font-size:12px;color:var(--accent);font-weight:600}
/* product detail */ /* product detail */