Versandfreigrenze pro Land, USt-ID, Rechtstexte+FAQ jetzt admin-editierbar

1) Versandkostenfreigrenze ist jetzt länderabhängig statt einer einzigen Zahl für alle:
   Deutschland 75 €, Österreich/Luxemburg 85 €, Schweiz 100 € (im Adminbereich unter
   "Versandkosten-Staffel" änderbar). Betrifft Warenkorb (live je nach gewähltem Land),
   Checkout-Berechnung, sowie alle Text-Hinweise (Topbar, FAQ, AGB) in allen 4 Sprachen.

2) Neues Feld "USt-IdNr." im Impressum (optional, admin-editierbar) — erscheint nur, wenn
   ausgefüllt.

3) Impressum, AGB, Datenschutzerklärung, Widerrufsbelehrung und FAQ sind jetzt vollständig
   im Adminbereich editierbar (neue Collection "⚖️ Rechtstexte & FAQ") statt fest im Code zu
   stehen. Jede Rechtsseite besteht aus frei bearbeitbaren Abschnitten (Überschrift + Text) —
   VanVan kann selbst Abschnitte ergänzen, ändern oder löschen. Freitext wird beim Anzeigen
   sicher escaped (kein kaputtes HTML durch Tippfehler möglich) und nackte Links automatisch
   klickbar gemacht. FAQ bleibt dabei weiterhin in allen 4 Sprachen separat pflegbar.

Ausführlich getestet: Freigrenze pro Land im echten Warenkorb verifiziert (Puppeteer,
84€ Warenwert -> DE kostenlos, AT/LU "noch 1€", CH "noch 16€"), Rechtstexte/FAQ/USt-ID
gegen den fertigen Build geprüft, EU-Streitschlichtung-Link-Bug (Satzzeichen wurde
mitverlinkt) gefunden und behoben.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
qcigano
2026-08-03 22:04:01 +02:00
co-authored by Claude Sonnet 5
parent 9a2c75ebe6
commit 0c1c8f075f
23 changed files with 495 additions and 310 deletions
+19
View File
@@ -0,0 +1,19 @@
// FAQ-Inhalte — seit 03.08.2026 admin-editierbar (siehe public/admin/config.yml, Collection
// "faq") statt fest in src/i18n/ui.ts zu stehen, damit VanVan Fragen/Antworten selbst pflegen
// kann. Weiterhin in allen 4 Sprachen (DE/EN/CH/FR), je eigene Liste.
import type { Locale } from "../i18n/config";
// @ts-ignore -- JSON-Import, zur Build-Zeit von Astro/Vite aufgelöst
import faqRaw from "../content/faq.json";
export interface FaqEintrag {
q: string;
a: string;
}
const faq = faqRaw as Record<Locale, FaqEintrag[]>;
export function faqEintraege(lang: Locale): FaqEintrag[] {
return faq[lang] ?? faq.de ?? [];
}
+63
View File
@@ -0,0 +1,63 @@
// Rechtstexte (Impressum, AGB, Datenschutz, Widerrufsbelehrung) — seit 03.08.2026 admin-editierbar
// (siehe public/admin/config.yml, Collections "impressum"/"agb"/"datenschutz"/"widerruf") statt
// fest im Code zu stehen. Jede Seite besteht aus einer frei bearbeitbaren Liste von Abschnitten
// (Überschrift + Text) — so kann VanVan selbst Abschnitte ergänzen/ändern/löschen, wenn sich z.B.
// Gesetze ändern oder ein neuer Dienstleister hinzukommt, ohne dass Code angefasst werden muss.
// @ts-ignore -- JSON-Import, zur Build-Zeit von Astro/Vite aufgelöst
import impressumRaw from "../content/impressum.json";
// @ts-ignore
import agbRaw from "../content/agb.json";
// @ts-ignore
import datenschutzRaw from "../content/datenschutz.json";
// @ts-ignore
import widerrufRaw from "../content/widerruf.json";
export interface LegalSection {
heading: string;
body: string;
}
export interface LegalPage {
sections: LegalSection[];
}
export interface ImpressumDaten extends LegalPage {
/** USt-IdNr. — optional (Kleinunternehmer nach §19 UStG haben oft keine), admin-editierbar,
* bewusst als eigenes Feld statt Teil des freien Fließtexts, damit sie nicht versehentlich beim
* Bearbeiten des Umsatzsteuer-Absatzes mitgelöscht wird. */
ustId?: string;
}
export const impressum = impressumRaw as ImpressumDaten;
export const agb = agbRaw as LegalPage;
export const datenschutz = datenschutzRaw as LegalPage;
export const widerruf = widerrufRaw as LegalPage;
/** Wandelt einen frei im Adminbereich editierbaren Rechtstext-Absatz SICHER in HTML um:
* 1. Escaped zuerst konsequent ALLES (&, <, >) — ein Tippfehler mit einem "<" im Admin kann so
* nie das Seiten-Layout kaputt machen oder (im denkbar schlimmsten Fall) Schadcode einschleusen.
* 2. Verlinkt danach automatisch nackte URLs (z.B. den EU-Streitschlichtung-Link) zu echten,
* klickbaren <a>-Tags — ganz ohne dass VanVan im Admin HTML schreiben müsste.
* 3. Leerzeilen werden zu neuen Absätzen, einzelne Zeilenumbrüche zu <br> (z.B. für Adresszeilen). */
export function rechtstextAlsHtml(body: string): string {
const escaped = (body ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const linkifiziert = escaped.replace(
/(https?:\/\/[^\s<]+)/g,
(url) => {
// Satzzeichen am Ende (Punkt, Komma, Klammer, ...) gehören zum Satz, nicht zum Link —
// sonst landet z.B. der Punkt am Satzende mit im href/Linktext.
const match = url.match(/^(.*?)([.,;:!?)]*)$/);
const [, reinerLink, satzzeichen] = match ?? [null, url, ""];
return `<a href="${reinerLink}" target="_blank" rel="noopener">${reinerLink}</a>${satzzeichen}`;
}
);
return linkifiziert
.split(/\n\s*\n/)
.filter((absatz) => absatz.trim().length > 0)
.map((absatz) => `<p>${absatz.trim().replace(/\n/g, "<br />")}</p>`)
.join("");
}
+20 -4
View File
@@ -1,5 +1,8 @@
// Versandkosten-Logik. Regeln (mit dem Nutzer am 02.08.2026 abgestimmt):
// 1. Warenkorb-Gesamtwert >= 75 € → Versand immer kostenlos, egal wie viele Produkte/Stück.
// Versandkosten-Logik. Regeln (mit dem Nutzer am 02.08.2026 abgestimmt, Freigrenze am
// 03.08.2026 auf länderspezifische Werte umgestellt — die tatsächlichen Versandkosten
// unterscheiden sich je nach Land zu stark, um eine einzige Grenze für alle zu rechtfertigen):
// 1. Warenkorb-Gesamtwert >= länderspezifischer Freigrenze → Versand immer kostenlos, egal wie
// viele Produkte/Stück (siehe FREIGRENZE_FALLBACK/freieVersandGrenze() unten).
// 2. Genau 1 Stück von genau 1 Produkt im Warenkorb → der am Produkt selbst hinterlegte
// Versandpreis für das Zielland gilt (falls hinterlegt, sonst Fallback auf die Staffel).
// 3. Alles andere (mehrere Produkte und/oder mehrere Stück) → Versandkosten-Staffel nach
@@ -16,6 +19,8 @@ export interface VersandStufe {
}
export interface Versandstaffel {
/** Versandkostenfreigrenze pro Land (€) — admin-editierbar, siehe config.yml "versandkosten". */
freigrenze?: Record<CountryCode, number>;
de: VersandStufe[];
at: VersandStufe[];
ch: VersandStufe[];
@@ -26,7 +31,18 @@ export interface Versandstaffel {
import versandstaffelRaw from "../content/versandstaffel.json";
export const versandstaffel = versandstaffelRaw as Versandstaffel;
export const FREE_SHIPPING_FROM = 75;
// Rückfallwerte, falls im Adminbereich (noch) keine Freigrenze hinterlegt ist — die aktuell
// gültigen, mit VanVan abgestimmten Standardwerte (03.08.2026): Deutschland 75 €, Österreich/
// Luxemburg 85 €, Schweiz 100 € (höhere Versandkosten in die Schweiz rechtfertigen eine höhere
// Freigrenze).
const FREIGRENZE_FALLBACK: Record<CountryCode, number> = { de: 75, at: 85, ch: 100, lu: 85 };
/** Versandkostenfreigrenze (€) für ein bestimmtes Land — admin-editierbar (siehe
* versandstaffel.json "freigrenze"), fällt bei fehlendem/unvollständigem Wert auf die
* abgestimmten Standardwerte zurück. */
export function freieVersandGrenze(land: CountryCode): number {
return versandstaffel.freigrenze?.[land] ?? FREIGRENZE_FALLBACK[land];
}
function tierLookup(land: CountryCode, warenwert: number): number {
const stufen = versandstaffel[land] ?? [];
@@ -57,7 +73,7 @@ export function berechneVersandkosten(
if (items.length === 0) return 0;
const warenwert = items.reduce((sum, i) => sum + i.preis * i.menge, 0);
if (warenwert >= FREE_SHIPPING_FROM) return 0;
if (warenwert >= freieVersandGrenze(land)) return 0;
const gesamtmenge = items.reduce((sum, i) => sum + i.menge, 0);
const istEinzelkauf = items.length === 1 && gesamtmenge === 1;