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:
Nick SzaunigandClaude Opus 5 committed 2026-09-19 07:59:15 +02:00
1 parent 29f59adc6b
commit 6d7a77c8dd
13 files changed
+383 -7

No files matched your search

+25
View File
@@ -12,6 +12,9 @@ import type { Locale } from "@/i18n/config";
const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
text: 1,
textarea: 3,
// Auswahlfelder rendern ein <select> und nutzen ROWS_BY_TYPE nicht — Wert
// nur vorhanden, damit dieses Record vollstaendig bleibt.
select: 0,
"list-simple": 4,
"list-titled": 5,
"list-numbered": 6,
@@ -57,6 +60,28 @@ function FieldEditor({
if (def.type === "video") {
return <AdminVideoField name={def.key} label={label} initialValue={row?.valueDe ?? ""} />;
}
if (def.type === "select") {
// Ohne gepflegten Wert ist der Vorgabewert vorausgewaehlt — genau der
// gilt zur Laufzeit ohnehin, die Anzeige soll das nicht anders
// darstellen als die Wirklichkeit.
const current = row?.valueDe?.trim() ? row.valueDe : def.getDefault("de");
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<select
name={def.key}
defaultValue={current}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
>
{(def.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{locale === "de" ? option.labelDe : option.labelEn}
</option>
))}
</select>
</label>
);
}
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
+37 -1
View File
@@ -44,7 +44,11 @@ vi.mock("@/lib/rate-limit", () => ({
getClientIp: vi.fn(async () => "203.0.113.7"),
}));
vi.mock("@/lib/shipping", () => ({ getShippingCostCents: vi.fn(async () => 700) }));
const getShippingRegion = vi.fn(async () => "DE" as const);
vi.mock("@/lib/shipping", () => ({
getShippingCostCents: vi.fn(async () => 700),
getShippingRegion: () => getShippingRegion(),
}));
vi.mock("@/lib/bulk-discount", () => ({ ensureBulkDiscountCode: vi.fn(async () => undefined) }));
vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" }));
vi.mock("@/i18n/locale", () => ({ getLocale: vi.fn(async () => "de") }));
@@ -108,6 +112,7 @@ beforeEach(() => {
rateLimit.mockReturnValue(true);
verifyTurnstileToken.mockResolvedValue(true);
getProductsByIds.mockResolvedValue([honey]);
getShippingRegion.mockResolvedValue("DE");
db.discountCode.findMany.mockResolvedValue([]);
db.inventory.updateMany.mockResolvedValue({ count: 1 });
db.order.create.mockResolvedValue(ORDER);
@@ -177,6 +182,37 @@ describe("Checkout-Route", () => {
expect(db.order.create).not.toHaveBeenCalled();
});
test("weist eine Lieferadresse ausserhalb des Liefergebiets ab", async () => {
// Die Laenderauswahl im Formular bietet nur zulaessige Laender an — das
// ist Bequemlichkeit, kein Schutz: dieser Endpunkt nimmt JSON entgegen
// und laesst sich ohne Formular aufrufen.
const response = await post({ ...validPayload, country: "AT" });
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringContaining("innerhalb Deutschlands"),
});
expect(db.order.create).not.toHaveBeenCalled();
});
test('laesst bei Liefergebiet "EU" ein anderes Mitgliedsland durch', async () => {
getShippingRegion.mockResolvedValue("EU" as never);
const response = await post({ ...validPayload, country: "AT" });
expect(response.status).toBe(200);
});
test('weist bei Liefergebiet "EU" ein Drittland trotzdem ab', async () => {
// Die Schweiz liegt in Europa, ist aber Zollausland.
getShippingRegion.mockResolvedValue("EU" as never);
const response = await post({ ...validPayload, country: "CH" });
expect(response.status).toBe(400);
expect(db.order.create).not.toHaveBeenCalled();
});
test("bricht ab, wenn der Bestand waehrend des Checkouts nicht mehr reicht", async () => {
// Das bedingte updateMany trifft keine Zeile mehr — jemand anders war
// schneller.
+22 -1
View File
@@ -9,7 +9,8 @@ import { getCurrentCustomer } from "@/lib/auth";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { isTrustedOrigin } from "@/lib/csrf";
import { verifyTurnstileToken } from "@/lib/turnstile";
import { getShippingCostCents } from "@/lib/shipping";
import { getShippingCostCents, getShippingRegion } from "@/lib/shipping";
import { isCountryAllowed } from "@/lib/shipping-region";
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
import { releaseUnpaidOrder } from "@/lib/order-release";
@@ -177,6 +178,26 @@ export async function POST(request: Request) {
{ status: 400 }
);
}
// Verbindliche Pruefung des Liefergebiets. Die Laenderauswahl im Formular
// bietet ohnehin nur zulaessige Laender an — das ist Bequemlichkeit, kein
// Schutz: dieser Endpunkt nimmt JSON entgegen und laesst sich ohne
// Formular aufrufen. Ohne die Pruefung hier entstuende eine Bestellung
// mit einer Lieferadresse, fuer die weder Versandweg noch
// Umsatzsteuerpflicht geklaert sind — und bezahlt waere sie dann auch
// schon.
const region = await getShippingRegion();
if (!isCountryAllowed(region, input.country)) {
return NextResponse.json(
{
error:
region === "DE"
? "Wir versenden derzeit ausschließlich innerhalb Deutschlands. Bitte gib eine Lieferadresse in Deutschland an oder wähle die Abholung."
: "Wir versenden derzeit ausschließlich innerhalb der Europäischen Union. Bitte gib eine Lieferadresse in der EU an oder wähle die Abholung.",
},
{ status: 400 }
);
}
}
const productIds = input.items.map((item) => item.productId);
+56 -1
View File
@@ -10,9 +10,28 @@ import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-
import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
import { useI18n } from "@/i18n/i18n-provider";
import { TurnstileWidget } from "@/components/turnstile-widget";
import { allowedCountryCodes, type ShippingRegion } from "@/lib/shipping-region";
type Fulfillment = "VERSAND" | "ABHOLUNG";
/**
* Laendername in der Sprache der Oberflaeche.
*
* Intl.DisplayNames statt 27 Laendernamen je Sprache im Woerterbuch: die
* Namen stehen bereits in jedem Browser und jeder Node-Laufzeit, und eine
* eigene Liste waere ab dem naechsten Beitritt oder der naechsten Umbenennung
* falsch. Faellt die Aufloesung aus, bleibt der Code stehen — unschoen, aber
* eindeutig, und die Bestellung geht trotzdem.
*/
function countryName(code: string, locale: string): string {
try {
return new Intl.DisplayNames([locale], { type: "region" }).of(code) ?? code;
} catch {
return code;
}
}
/** Hausanschrift oder DHL-Packstation — nur bei fulfillment === "VERSAND" relevant. */
type DeliveryTarget = "ADRESSE" | "PACKSTATION";
@@ -42,6 +61,7 @@ export function CheckoutForm({
bulkDiscountTiers,
packstationSearchAvailable = false,
turnstileSiteKey,
shippingRegion = "DE",
}: {
initialValues?: CheckoutInitialValues;
tiers: ShippingTierLike[];
@@ -54,6 +74,13 @@ export function CheckoutForm({
packstationSearchAvailable?: boolean;
/** Leer, wenn Turnstile nicht konfiguriert ist — dann entfaellt das Widget. */
turnstileSiteKey?: string;
/**
* Wohin geliefert wird (Admin-Bereich → Inhalte → Liefergebiet). Steuert
* die Laenderauswahl unten. Vorgabe ist der engste Zuschnitt: waere sie
* "EU", wuerde ein vergessenes Durchreichen der Prop stillschweigend mehr
* Laender anbieten, als der Shop beliefert.
*/
shippingRegion?: ShippingRegion;
}) {
const { t, locale } = useI18n();
const items = useCartStore((s) => s.items);
@@ -64,6 +91,9 @@ export function CheckoutForm({
// Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit
// Token-Rueckgabe statt eines versteckten Feldes.
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const countryCodes = allowedCountryCodes(shippingRegion);
// Nur eine Auswahl anbieten, wenn es etwas zu waehlen gibt.
const [country, setCountry] = useState(countryCodes[0]);
const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -157,7 +187,7 @@ export function CheckoutForm({
city: usesPackstation
? selectedPackstation?.city
: String(formData.get("city") ?? "") || undefined,
country: "DE",
country,
packstationNumber: usesPackstation ? selectedPackstation?.number : undefined,
postNumber: usesPackstation
? String(formData.get("postNumber") ?? "").trim() || undefined
@@ -327,6 +357,31 @@ export function CheckoutForm({
required
/>
</div>
{/* Bei nur einem Lieferland waere eine Auswahl mit einem
Eintrag eine Attrappe — dann steht dort ein Satz, der das
Gebiet benennt, bevor jemand die Adresse eintippt und erst
beim Abschicken erfaehrt, dass daraus nichts wird. */}
{countryCodes.length > 1 ? (
<label className="block text-sm">
<span className="text-ink/70">{t.checkoutPage.countryLabel}</span>
<select
name="country"
value={country}
onChange={(event) => setCountry(event.target.value)}
autoComplete="country"
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
>
{countryCodes.map((code) => (
<option key={code} value={code}>
{countryName(code, locale)}
</option>
))}
</select>
</label>
) : (
<p className="text-xs text-ink/65">{t.checkoutPage.shippingRegionDeNote}</p>
)}
</div>
) : null}
+8 -2
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { getCurrentCustomer } from "@/lib/auth";
import { getAddressesForCustomer } from "@/lib/account-data";
import { getShippingTiers } from "@/lib/shipping";
import { getShippingTiers, getShippingRegion } from "@/lib/shipping";
import { getBulkDiscountTiers } from "@/lib/bulk-discount";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
@@ -20,7 +20,11 @@ export async function generateMetadata(): Promise<Metadata> {
}
export default async function CheckoutPage() {
const [tiers, bulkDiscountTiers] = await Promise.all([getShippingTiers(), getBulkDiscountTiers()]);
const [tiers, bulkDiscountTiers, shippingRegion] = await Promise.all([
getShippingTiers(),
getBulkDiscountTiers(),
getShippingRegion(),
]);
const tierValues = tiers.map((t) => ({
minQuantity: t.minQuantity,
maxQuantity: t.maxQuantity,
@@ -33,6 +37,7 @@ export default async function CheckoutPage() {
tiers={tierValues}
bulkDiscountTiers={bulkDiscountTiers}
packstationSearchAvailable={isPackstationSearchConfigured()}
shippingRegion={shippingRegion}
/>;
// getAddressesForCustomer sortiert Standardadresse (isDefault) zuerst, sonst
@@ -57,5 +62,6 @@ export default async function CheckoutPage() {
tiers={tierValues}
bulkDiscountTiers={bulkDiscountTiers}
packstationSearchAvailable={isPackstationSearchConfigured()}
shippingRegion={shippingRegion}
/>;
}
+15 -2
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import { LegalPage } from "@/components/legal-page";
import { getShippingTiers } from "@/lib/shipping";
import { getShippingTiers, getShippingRegion } from "@/lib/shipping";
import { formatPrice } from "@/lib/format";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
@@ -14,7 +14,7 @@ export const dynamic = "force-dynamic";
export default async function VersandZahlungPage() {
const locale = await getLocale();
const t = getDictionary(locale);
const tiers = await getShippingTiers();
const [tiers, region] = await Promise.all([getShippingTiers(), getShippingRegion()]);
const tierParagraphs =
tiers.length > 0
@@ -41,6 +41,19 @@ export default async function VersandZahlungPage() {
heading: t.versandZahlungPage.shippingHeading,
paragraphs: [t.versandZahlungPage.shippingIntro, ...tierParagraphs, t.versandZahlungPage.pickupNote],
},
{
// Eigener Abschnitt statt eines Nebensatzes im Versandtext: wohin
// geliefert wird, entscheidet vor dem Warenkorb, ob ein Kauf
// ueberhaupt zustande kommen kann. Welcher Satz hier steht, steuert
// der Admin-Bereich (Inhalte → Firmendaten → Liefergebiet) — und
// derselbe Wert weist im Checkout Bestellungen ausserhalb des
// Gebiets ab, die Seite kann also nicht etwas anderes behaupten als
// der Shop tut.
heading: t.versandZahlungPage.regionHeading,
paragraphs: [
region === "EU" ? t.versandZahlungPage.regionTextEu : t.versandZahlungPage.regionTextDe,
],
},
{
heading: t.versandZahlungPage.paymentHeading,
paragraphs: [t.versandZahlungPage.paymentText],