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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Generated
+10
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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
@@ -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 1–100, bei fixed in Cent, bei freeshipping ignoriert. Codes werden case-insensitiv geprüft.',
|
'discounts.value: bei percent 1–100, 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
@@ -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) {
|
||||||
|
|||||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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' });
|
||||||
|
}
|
||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
+229
-11
@@ -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);
|
||||||
}
|
}
|
||||||
recordEvent({ type: 'purchase', path: '/bestellung-erfolgreich', value_cents: total_cents || 0, meta: { number } });
|
// 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 } });
|
||||||
|
}
|
||||||
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: '2–4', sort: 1, active: 1 },
|
||||||
|
{ name: 'Österreich & Schweiz', countries: 'AT,CH', price_cents: 990, free_over_cents: null, delivery_days: '4–7', sort: 2, active: 1 },
|
||||||
|
{ name: 'EU', countries: 'EU', price_cents: 1290, free_over_cents: null, delivery_days: '5–9', 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
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -2,27 +2,51 @@
|
|||||||
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();
|
||||||
setSetting('shop_name', f.get('shop_name') || 'hd-commerce');
|
const action = String(f.get('_action') || 'general');
|
||||||
setSetting('shop_tagline', f.get('shop_tagline') || '');
|
if (action === 'features') {
|
||||||
setSetting('shop_email', f.get('shop_email') || '');
|
for (const k of FEATURE_KEYS) setSetting(k, f.get(k) === 'on' ? '1' : '0');
|
||||||
setSetting('brand_accent', f.get('brand_accent') || '#b8566a');
|
flash = 'Module aktualisiert.';
|
||||||
setSetting('brand_accent_dark', f.get('brand_accent_dark') || '#8d3f50');
|
} else if (action === 'payment') {
|
||||||
setSetting('currency', f.get('currency') || 'EUR');
|
setSetting('payment_provider', String(f.get('payment_provider') || ''));
|
||||||
setSetting('free_shipping_cents', String(Math.round(parseFloat(String(f.get('free_shipping') || '49').replace(',', '.')) * 100) || 4900));
|
flash = 'Zahlungsanbieter gespeichert.';
|
||||||
flash = 'Einstellungen gespeichert.';
|
} else {
|
||||||
|
setSetting('shop_name', f.get('shop_name') || 'hd-commerce');
|
||||||
|
setSetting('shop_tagline', f.get('shop_tagline') || '');
|
||||||
|
setSetting('shop_email', f.get('shop_email') || '');
|
||||||
|
setSetting('brand_accent', f.get('brand_accent') || '#b8566a');
|
||||||
|
setSetting('brand_accent_dark', f.get('brand_accent_dark') || '#8d3f50');
|
||||||
|
setSetting('currency', f.get('currency') || 'EUR');
|
||||||
|
setSetting('free_shipping_cents', String(Math.round(parseFloat(String(f.get('free_shipping') || '49').replace(',', '.')) * 100) || 4900));
|
||||||
|
flash = 'Einstellungen gespeichert.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = getSettings();
|
const 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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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="2–4" /></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>
|
||||||
+65
-42
@@ -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) => {
|
||||||
name: i.name, size: i.size || '', qty: Math.max(1, parseInt(i.qty) || 1),
|
const prod = i.slug ? getProductBySlug(i.slug) : null;
|
||||||
priceCents: Math.round(Number(i.priceCents) || Number(i.price) * 100 || 0), image: i.image || '',
|
return {
|
||||||
}));
|
slug: i.slug || (prod && prod.slug) || '',
|
||||||
|
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 || '',
|
||||||
|
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 });
|
|
||||||
} catch (e) { return json({ url: `/bestellung-erfolgreich?order=${order.number}&demo=1` }); }
|
|
||||||
}
|
}
|
||||||
return json({ url: `/bestellung-erfolgreich?order=${order.number}&demo=1` });
|
|
||||||
|
return json({ url: pay.redirectUrl });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }); }
|
||||||
@@ -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 })) });
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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>
|
</div>
|
||||||
<button class="btn btn-primary btn-lg btn-block" type="submit" id="coBtn" style="margin-top:8px">Kostenpflichtig bestellen</button>
|
<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'; });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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(); }; });
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user