Kategorie
{cats.map((c) => ( ))}
diff --git a/src/pages/admin/versand/index.astro b/src/pages/admin/versand/index.astro
new file mode 100644
index 0000000..d8cad34
--- /dev/null
+++ b/src/pages/admin/versand/index.astro
@@ -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('.', ','));
+---
+
+
+ {flash &&
✓ {flash}
}
+
+
+
Versandzonen
+
+
+ Zone Länder Preis Gratis ab Lieferzeit Status
+
+ {zones.length === 0 ? (Keine Zonen ) :
+ zones.map((z) => (
+
+ {z.name}
+ {z.countries}
+ {formatPrice(z.price_cents)}
+ {z.free_over_cents != null ? formatPrice(z.free_over_cents) : '—'}
+ {z.delivery_days || '—'}
+ {z.active ? 'Aktiv' : 'Inaktiv'}
+
+ Bearbeiten
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/api/checkout.js b/src/pages/api/checkout.js
index e44d0a6..2766405 100644
--- a/src/pages/api/checkout.js
+++ b/src/pages/api/checkout.js
@@ -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;
-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' } }); }
export async function POST({ request }) {
@@ -10,74 +15,92 @@ export async function POST({ request }) {
const contact = body.contact || {};
if (!items.length) return json({ error: 'Warenkorb leer' }, 400);
- const lineItems = items.map((i) => ({
- 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 || '',
- }));
+ const lineItems = items.map((i) => {
+ const prod = i.slug ? getProductBySlug(i.slug) : null;
+ return {
+ slug: i.slug || (prod && prod.slug) || '',
+ name: i.name, size: i.size || '', qty: Math.max(1, parseInt(i.qty) || 1),
+ 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 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 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).
- let discount = null; // { id, code, amountCents, freeShipping }
+ let discount = null;
const rawCode = String(body.code || '').trim();
if (rawCode) {
const v = validateDiscount(rawCode, subtotal, email || undefined);
if (v.ok) discount = v;
}
- // Kein (gültiger) Code? Bestes automatisches Discount anwenden, falls Bedingungen erfüllt.
if (!discount) {
const auto = bestAutoDiscount(subtotal);
if (auto && auto.ok) discount = auto;
}
let discountCents = 0;
+ let discountFreeShipping = false;
if (discount) {
- if (discount.freeShipping) { shipping = 0; }
- else { discountCents = Math.min(discount.amountCents, subtotal); }
+ if (discount.freeShipping) discountFreeShipping = true;
+ 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({
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,
+ tax_cents: taxCents, shipping_cents: shippingCents, country,
});
- // Einlösung verbuchen (used_count + Redemption).
if (discount) {
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 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)) {
- try {
- 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 : ''}` } } })),
- ...(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` }); }
+ // Demo-Provider: Order sofort als bezahlt markieren + Bestätigungsmail auslösen.
+ if (pay.provider === 'demo') {
+ const res = markOrderPaid(order.id, { payment_provider: 'demo', payment_id: '' });
+ if (res.changed) { try { await sendOrderConfirmation(res.order); } catch {} }
+ } else if (pay.paymentId) {
+ // Payment-ID an der Order vermerken, damit der Webhook sie zuordnen kann.
+ setOrderPayment(order.id, { payment_id: pay.paymentId, payment_provider: pay.provider });
}
- return json({ url: `/bestellung-erfolgreich?order=${order.number}&demo=1` });
+
+ return json({ url: pay.redirectUrl });
}
diff --git a/src/pages/api/payments/webhook.js b/src/pages/api/payments/webhook.js
new file mode 100644
index 0000000..11953ce
--- /dev/null
+++ b/src/pages/api/payments/webhook.js
@@ -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 }); }
diff --git a/src/pages/api/shipping-quote.js b/src/pages/api/shipping-quote.js
new file mode 100644
index 0000000..f3b0733
--- /dev/null
+++ b/src/pages/api/shipping-quote.js
@@ -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 })) });
+}
diff --git a/src/pages/api/subscribe.js b/src/pages/api/subscribe.js
index 03211a3..dba2111 100644
--- a/src/pages/api/subscribe.js
+++ b/src/pages/api/subscribe.js
@@ -1,8 +1,10 @@
-import { addSubscriber } from '../../lib/store.js';
+import { addSubscriber, feature } from '../../lib/store.js';
export const prerender = false;
function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); }
export async function POST({ request }) {
+ // Feature-Flag: Newsletter abschaltbar.
+ if (!feature('feature_newsletter')) return json({ ok: false, error: 'Newsletter deaktiviert' }, 403);
let b;
try { b = await request.json(); } catch { return json({ ok: false }, 400); }
const email = (b.email || '').trim();
diff --git a/src/pages/bestellung-erfolgreich.astro b/src/pages/bestellung-erfolgreich.astro
index 76b4979..8f2ccd3 100644
--- a/src/pages/bestellung-erfolgreich.astro
+++ b/src/pages/bestellung-erfolgreich.astro
@@ -1,20 +1,37 @@
---
import Base from '../layouts/Base.astro';
+import { getOrderByNumber, formatPrice } from '../lib/store.js';
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 order = orderNum ? getOrderByNumber(orderNum) : null;
+const subtotal = order ? (order.total_cents + (order.discount_cents || 0) - (order.shipping_cents || 0)) : 0;
---
-
+
Vielen Dank für deine Bestellung!
- {order &&
Deine Bestellnummer: {order}
}
+ {orderNum &&
Deine Bestellnummer: {orderNum}
}
Wir haben deine Bestellung erhalten und melden uns per E-Mail mit den Versanddetails.
{demo &&
Demo-Hinweis: Diese Bestellung wurde ohne echte Zahlung im Demo-Modus angelegt.
}
-
Weiter einkaufen
+
+ {order && (
+
+ {order.items.map((i) => (
+
{i.qty}× {i.name}{i.size && i.size !== 'One Size' ? ` (${i.size})` : ''} {formatPrice(i.priceCents * i.qty)}
+ ))}
+
Zwischensumme {formatPrice(subtotal)}
+ {order.discount_cents > 0 && (
Rabatt{order.discount_code ? ` (${order.discount_code})` : ''} −{formatPrice(order.discount_cents)}
)}
+
Versand {order.shipping_cents === 0 ? 'Kostenlos' : formatPrice(order.shipping_cents)}
+
Gesamt {formatPrice(order.total_cents)}
+ {order.tax_cents > 0 && (
inkl. MwSt. {formatPrice(order.tax_cents)}
)}
+
+ )}
+
+
diff --git a/src/pages/checkout.astro b/src/pages/checkout.astro
index d1bd82c..089463b 100644
--- a/src/pages/checkout.astro
+++ b/src/pages/checkout.astro
@@ -1,14 +1,29 @@
---
import Base from '../layouts/Base.astro';
-import { getSetting } from '../lib/store.js';
-const freeShip = Number(getSetting('free_shipping_cents', '4900')) || 4900;
+import { getSetting, listActiveShippingZones, resolvePaymentProvider } from '../lib/store.js';
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';
---
Zur Kasse
- {!hasStripe && (
Demo-Modus: Es ist kein echter Stripe-Schlüssel hinterlegt — die Bestellung wird ohne Zahlung abgeschlossen.
)}
+ {demoMode && (
Demo-Modus: Kein echter Zahlungsanbieter konfiguriert — die Bestellung wird ohne Zahlung abgeschlossen.
)}
-
Kostenpflichtig bestellen
+
Zahlungspflichtig bestellen
@@ -38,46 +57,66 @@ const hasStripe = /^sk_(test|live)_[A-Za-z0-9]{16,}/.test((process.env.STRIPE_SE
-