feat: Liefergebiet im Admin-Bereich umschaltbar (Deutschland / EU)
Admin-Bereich → Inhalte → Firmendaten → "Liefergebiet". Auswahl zwischen
"Nur Deutschland" und "Europäische Union". Der Wert steuert drei Stellen
gleichzeitig, damit sie nicht auseinanderlaufen koennen:
1. Checkout-Formular: bei "EU" eine Laenderauswahl mit den 27
Mitgliedstaaten, bei "Nur Deutschland" stattdessen ein Satz, der das
Gebiet benennt — bevor jemand die Adresse eintippt und erst beim
Abschicken erfaehrt, dass daraus nichts wird. Eine Auswahl mit einem
einzigen Eintrag waere eine Attrappe.
2. api/checkout/route.ts: verbindliche Pruefung. Die Auswahl im Formular
ist Bequemlichkeit, kein Schutz — der Endpunkt nimmt JSON entgegen und
laesst sich ohne Formular aufrufen. Ohne diese Pruefung entstuende eine
Bestellung mit einer Lieferadresse, fuer die weder Versandweg noch
Umsatzsteuerpflicht geklaert sind, und bezahlt waere sie dann auch
schon. Abgewiesen wird mit einer Meldung, die sagt, was stattdessen
geht (Adresse im Gebiet oder Abholung).
3. Seite "Versand und Zahlung": eigener Abschnitt "Liefergebiet" mit dem
passenden Satz. Eigener Abschnitt statt Nebensatz, weil die Frage vor
dem Warenkorb entscheidet, ob ein Kauf ueberhaupt zustande kommt.
Die Vorgabe ist "Nur Deutschland", und zwar an jeder Stelle einzeln: ein
fehlender oder unsinniger Wert, eine nicht durchgereichte Prop — im
Zweifel liefert der Shop in WENIGER Laender, nie in mehr.
"EU" heisst die 27 Mitgliedstaaten, nicht Europa im geografischen Sinn.
Schweiz, Norwegen und das Vereinigte Koenigreich sind Zollausland; dorthin
zu liefern braucht mehr als einen Haken im Admin-Bereich.
Technisch dazugekommen:
- lib/shipping-region.ts, reine Logik ohne Datenbankzugriff (wie
shipping-calc.ts), 11 Tests.
- content-registry.ts kennt jetzt den Feldtyp "select" mit "options" —
ein Freitextfeld waere hier falsch: ein Tippfehler im Liefergebiet
wuerde stillschweigend auf den Vorgabewert zurueckfallen, und niemand
saehe, dass die Umstellung nicht gegriffen hat.
- Laendernamen ueber Intl.DisplayNames statt 27 Namen je Sprache im
Woerterbuch: eine eigene Liste waere ab dem naechsten Beitritt falsch.
- 3 weitere Tests in der Checkout-Route (Abweisung, EU-Mitglied durch,
Drittland trotzdem abgewiesen).
tsc: 0 Fehler. vitest: 350/350 gruen (zuvor 336). eslint: 0 Warnungen.
"next build" laeuft durch.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
29f59adc6b
commit
6d7a77c8dd
13 files changed
+383
-7
No files matched your search
@@ -1,11 +1,14 @@
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import { DEFAULT_SHIPPING_REGION } from "@/lib/shipping-region";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
import type { TitledText, LegalSectionContent } from "@/i18n/dictionary-type";
|
||||
|
||||
export type ContentFieldType =
|
||||
| "text"
|
||||
| "textarea"
|
||||
/** Feste Auswahl statt freiem Text — siehe "options" an ContentFieldDef. */
|
||||
| "select"
|
||||
| "list-simple"
|
||||
| "list-titled"
|
||||
| "list-numbered"
|
||||
@@ -23,6 +26,13 @@ export interface ContentFieldDef {
|
||||
localized: boolean;
|
||||
/** Liefert den aktuellen Fallback-Text im selben Format, das der Parser fuer diesen Typ erwartet. */
|
||||
getDefault: (locale: Locale) => string;
|
||||
/**
|
||||
* Nur bei type: "select" — die waehlbaren Werte. Ein freies Textfeld waere
|
||||
* hier falsch: ein Tippfehler im Liefergebiet wuerde stillschweigend auf
|
||||
* den Vorgabewert zurueckfallen, und niemand saehe, dass die Umstellung
|
||||
* nicht gegriffen hat.
|
||||
*/
|
||||
options?: { value: string; labelDe: string; labelEn: string }[];
|
||||
}
|
||||
|
||||
function joinSimpleList(items: string[]): string {
|
||||
@@ -56,6 +66,25 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [
|
||||
{ key: "company.zip", group: "company", labelDe: "PLZ", labelEn: "ZIP", type: "text", localized: false, getDefault: () => siteConfig.address.zip },
|
||||
{ key: "company.city", group: "company", labelDe: "Ort", labelEn: "City", type: "text", localized: false, getDefault: () => siteConfig.address.city },
|
||||
|
||||
// --- Liefergebiet ---
|
||||
// Steuert dreierlei zugleich: die Laenderauswahl im Checkout, die
|
||||
// verbindliche Pruefung beim Anlegen der Bestellung (api/checkout/route.ts)
|
||||
// und den Hinweis auf der Seite "Versand und Zahlung". Bewusst eine
|
||||
// Auswahl und kein Freitext — siehe "options" oben.
|
||||
{
|
||||
key: "shipping.region",
|
||||
group: "company",
|
||||
labelDe: "Liefergebiet",
|
||||
labelEn: "Shipping region",
|
||||
type: "select",
|
||||
localized: false,
|
||||
getDefault: () => DEFAULT_SHIPPING_REGION,
|
||||
options: [
|
||||
{ value: "DE", labelDe: "Nur Deutschland", labelEn: "Germany only" },
|
||||
{ value: "EU", labelDe: "Europäische Union", labelEn: "European Union" },
|
||||
],
|
||||
},
|
||||
|
||||
// --- Homepage (lokalisiert) ---
|
||||
{ key: "home.hero.intro", group: "home", labelDe: "Hero: Einleitungstext", labelEn: "Hero: Intro text", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.hero.intro },
|
||||
{ key: "home.hero.footnote", group: "home", labelDe: "Hero: Fußnote", labelEn: "Hero: Footnote", type: "text", localized: true, getDefault: (l) => getDictionary(l).home.hero.footnote },
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Tests fuer das Liefergebiet.
|
||||
*
|
||||
* Die Frage "darf in dieses Land geliefert werden" entscheidet, ob eine
|
||||
* Bestellung zustande kommt. Ein Fehler in die eine Richtung sperrt zahlende
|
||||
* Kundschaft aus, einer in die andere erzeugt Lieferungen in Laender, fuer
|
||||
* die weder Steuer noch Zoll geklaert sind.
|
||||
*/
|
||||
import { describe, test, expect } from "vitest";
|
||||
import {
|
||||
DEFAULT_SHIPPING_REGION,
|
||||
EU_COUNTRY_CODES,
|
||||
allowedCountryCodes,
|
||||
isCountryAllowed,
|
||||
parseShippingRegion,
|
||||
} from "./shipping-region";
|
||||
|
||||
describe("parseShippingRegion", () => {
|
||||
test("erkennt die beiden gueltigen Werte", () => {
|
||||
expect(parseShippingRegion("DE")).toBe("DE");
|
||||
expect(parseShippingRegion("EU")).toBe("EU");
|
||||
});
|
||||
|
||||
test("ist unempfindlich gegen Schreibweise und Leerzeichen", () => {
|
||||
expect(parseShippingRegion(" eu ")).toBe("EU");
|
||||
expect(parseShippingRegion("de")).toBe("DE");
|
||||
});
|
||||
|
||||
test("faellt bei fehlendem oder unsinnigem Wert auf den engsten Zuschnitt zurueck", () => {
|
||||
// Wichtige Richtung: im Zweifel WENIGER Laender, nicht mehr. Ein Shop
|
||||
// liefert nie versehentlich weiter, als er wollte.
|
||||
expect(parseShippingRegion(null)).toBe("DE");
|
||||
expect(parseShippingRegion("")).toBe("DE");
|
||||
expect(parseShippingRegion("WELTWEIT")).toBe("DE");
|
||||
expect(DEFAULT_SHIPPING_REGION).toBe("DE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowedCountryCodes", () => {
|
||||
test('"DE" erlaubt genau ein Land', () => {
|
||||
expect(allowedCountryCodes("DE")).toEqual(["DE"]);
|
||||
});
|
||||
|
||||
test('"EU" erlaubt die 27 Mitgliedstaaten, sortiert und ohne Dubletten', () => {
|
||||
const codes = allowedCountryCodes("EU");
|
||||
expect(codes).toHaveLength(27);
|
||||
expect(codes).toEqual([...codes].sort());
|
||||
expect(new Set(codes).size).toBe(27);
|
||||
});
|
||||
|
||||
test("Drittlaender in Europa sind NICHT enthalten", () => {
|
||||
// Schweiz, Norwegen und das Vereinigte Koenigreich liegen in Europa, sind
|
||||
// aber Zollausland — dorthin zu liefern braucht mehr als einen Haken im
|
||||
// Admin-Bereich.
|
||||
const codes = allowedCountryCodes("EU");
|
||||
expect(codes).not.toContain("CH");
|
||||
expect(codes).not.toContain("NO");
|
||||
expect(codes).not.toContain("GB");
|
||||
});
|
||||
|
||||
test("Griechenland steht als ISO-Code GR, nicht als EL", () => {
|
||||
expect(EU_COUNTRY_CODES as readonly string[]).toContain("GR");
|
||||
expect(EU_COUNTRY_CODES as readonly string[]).not.toContain("EL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCountryAllowed", () => {
|
||||
test('bei "DE" kommt nur Deutschland durch', () => {
|
||||
expect(isCountryAllowed("DE", "DE")).toBe(true);
|
||||
expect(isCountryAllowed("DE", "AT")).toBe(false);
|
||||
expect(isCountryAllowed("DE", "FR")).toBe(false);
|
||||
});
|
||||
|
||||
test('bei "EU" kommen Mitgliedstaaten durch, Drittlaender nicht', () => {
|
||||
expect(isCountryAllowed("EU", "AT")).toBe(true);
|
||||
expect(isCountryAllowed("EU", "DE")).toBe(true);
|
||||
expect(isCountryAllowed("EU", "CH")).toBe(false);
|
||||
expect(isCountryAllowed("EU", "US")).toBe(false);
|
||||
});
|
||||
|
||||
test("akzeptiert Kleinschreibung und Leerzeichen aus dem Formular", () => {
|
||||
expect(isCountryAllowed("EU", " at ")).toBe(true);
|
||||
expect(isCountryAllowed("DE", "de")).toBe(true);
|
||||
});
|
||||
|
||||
test("ein fehlendes Land ist nie erlaubt", () => {
|
||||
expect(isCountryAllowed("EU", null)).toBe(false);
|
||||
expect(isCountryAllowed("EU", "")).toBe(false);
|
||||
expect(isCountryAllowed("EU", " ")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Liefergebiet des Shops.
|
||||
*
|
||||
* Bewusst eine ausdrueckliche Entscheidung und keine Ableitung aus den
|
||||
* Versandstaffeln: ob in ein Land geliefert wird, haengt nicht am Preis,
|
||||
* sondern an Steuerpflichten (Umsatzsteuer im Bestimmungsland ab der
|
||||
* EU-Lieferschwelle), Zoll und den eigenen Kapazitaeten. Das gehoert
|
||||
* entschieden, nicht erraten.
|
||||
*
|
||||
* Reine Logik ohne Datenbankzugriff — dieselbe Trennung wie bei
|
||||
* shipping-calc.ts, damit sie sich ohne DATABASE_URL testen laesst. Den
|
||||
* aktuell gepflegten Wert liest lib/shipping.ts.
|
||||
*/
|
||||
|
||||
export const SHIPPING_REGIONS = ["DE", "EU"] as const;
|
||||
export type ShippingRegion = (typeof SHIPPING_REGIONS)[number];
|
||||
|
||||
/** Vorgabe: der engste Zuschnitt. Ein Shop liefert nie versehentlich weiter, als er wollte. */
|
||||
export const DEFAULT_SHIPPING_REGION: ShippingRegion = "DE";
|
||||
|
||||
/**
|
||||
* Die 27 Mitgliedstaaten der Europaeischen Union als ISO-3166-1-alpha-2.
|
||||
*
|
||||
* Bewusst die EU und nicht "Europa" im geografischen Sinn: die Schweiz,
|
||||
* Norwegen und das Vereinigte Koenigreich liegen in Europa, sind aber
|
||||
* Drittlaender mit Zollanmeldung und Einfuhrumsatzsteuer. Wer sie beliefern
|
||||
* will, braucht mehr als einen Haken im Admin-Bereich.
|
||||
*
|
||||
* Griechenland steht als "GR" (ISO), nicht als "EL" (EU-interne Schreibweise).
|
||||
*/
|
||||
export const EU_COUNTRY_CODES = [
|
||||
"AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI",
|
||||
"FR", "GR", "HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT",
|
||||
"NL", "PL", "PT", "RO", "SE", "SI", "SK",
|
||||
] as const;
|
||||
|
||||
/** Wandelt den in der Datenbank gepflegten Text in ein Liefergebiet. */
|
||||
export function parseShippingRegion(raw: string | null | undefined): ShippingRegion {
|
||||
const value = raw?.trim().toUpperCase();
|
||||
return SHIPPING_REGIONS.includes(value as ShippingRegion)
|
||||
? (value as ShippingRegion)
|
||||
: DEFAULT_SHIPPING_REGION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Laender, in die geliefert wird — aufsteigend sortiert, damit die
|
||||
* Auswahl im Checkout eine stabile Reihenfolge hat.
|
||||
*/
|
||||
export function allowedCountryCodes(region: ShippingRegion): string[] {
|
||||
return region === "EU" ? [...EU_COUNTRY_CODES].sort() : ["DE"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf in dieses Land geliefert werden?
|
||||
*
|
||||
* Gross-/Kleinschreibung und Leerzeichen werden abgefangen: der Wert kommt
|
||||
* aus einem Formularfeld, und eine Bestellung an einer Schreibweise scheitern
|
||||
* zu lassen waere kein Schutz, sondern ein Fehler.
|
||||
*/
|
||||
export function isCountryAllowed(region: ShippingRegion, country: string | null | undefined): boolean {
|
||||
const code = country?.trim().toUpperCase();
|
||||
if (!code) return false;
|
||||
return allowedCountryCodes(region).includes(code);
|
||||
}
|
||||
@@ -1,7 +1,26 @@
|
||||
import { cache } from "react";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { resolveShippingCostCents } from "@/lib/shipping-calc";
|
||||
import { getContentValue } from "@/lib/content";
|
||||
import { parseShippingRegion, type ShippingRegion } from "@/lib/shipping-region";
|
||||
import type { ShippingTier } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Aktuell gepflegtes Liefergebiet (Admin-Bereich → Inhalte → Firmendaten).
|
||||
*
|
||||
* In React-cache() gehuellt wie getCompanyInfo: der Wert wird beim Rendern
|
||||
* einer Checkout-Seite mehrfach gebraucht (Laenderauswahl im Formular,
|
||||
* Hinweistext daneben), und das waeren sonst mehrere Abfragen derselben einen
|
||||
* Zeile. Die Deduplizierung gilt je Request — eine Umstellung im
|
||||
* Admin-Bereich wirkt sofort.
|
||||
*/
|
||||
export const getShippingRegion = cache(async (): Promise<ShippingRegion> => {
|
||||
// Nicht lokalisiert: das Liefergebiet ist eine Tatsache des Betriebs, keine
|
||||
// Textfassung. getContentValue liefert fuer solche Schluessel unabhaengig
|
||||
// von der Sprache denselben Wert.
|
||||
return parseShippingRegion(await getContentValue("shipping.region", "de"));
|
||||
});
|
||||
|
||||
export async function getShippingTiers(): Promise<ShippingTier[]> {
|
||||
return prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user