Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bdf4df04a | ||
|
|
6d7a77c8dd |
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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,50 @@ 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("weist Helgoland ab, obwohl es in Deutschland liegt", async () => {
|
||||
// Helgoland liegt ausserhalb des EU-Zollgebiets: dorthin ist jede
|
||||
// Sendung eine Ausfuhr mit Zollinhaltserklaerung. Der Laendercode allein
|
||||
// wuerde das durchgehen lassen.
|
||||
const response = await post({ ...validPayload, zip: "27498" });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
error: expect.stringContaining("Helgoland"),
|
||||
});
|
||||
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.
|
||||
|
||||
@@ -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 { findCustomsExcludedArea, 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,42 @@ 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 }
|
||||
);
|
||||
}
|
||||
|
||||
// Der Laendercode allein genuegt nicht: einige Gebiete gehoeren zu einem
|
||||
// EU-Mitgliedstaat, liegen aber ausserhalb des Zoll- und/oder
|
||||
// Steuergebiets der Union. Dorthin ist jede Sendung eine Ausfuhr mit
|
||||
// Zollinhaltserklaerung — zwei davon liegen in Deutschland (Helgoland und
|
||||
// Buesingen am Hochrhein), sind also selbst bei "Nur Deutschland"
|
||||
// betroffen. Erkennbar sind sie nur an der Postleitzahl.
|
||||
const excludedArea = findCustomsExcludedArea(input.country, input.zip);
|
||||
if (excludedArea) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Nach ${excludedArea} können wir leider nicht versenden: dorthin ist jede Sendung zollpflichtig. Bitte gib eine andere Lieferadresse an oder wähle die Abholung.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const productIds = input.items.map((item) => item.productId);
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>;
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -391,6 +391,9 @@ export const de: Dictionary = {
|
||||
tierRange: (min, max, price) => `${min}–${max} Gläser/Artikel: ${price}`,
|
||||
tierMinimum: (min, price) => `Ab ${min} Gläsern/Artikeln: ${price}`,
|
||||
pickupNote: "Alternativ kannst du deine Bestellung nach Absprache direkt bei uns in Altenmünster abholen. Dabei fallen keine Versandkosten an.",
|
||||
regionHeading: "Liefergebiet",
|
||||
regionTextDe: "Versand ist nur innerhalb Deutschlands möglich. Bestellungen mit einer Lieferadresse außerhalb Deutschlands können wir derzeit leider nicht annehmen. Ausgenommen sind außerdem Helgoland und Büsingen am Hochrhein: beide liegen außerhalb des EU-Zollgebiets, dorthin wäre jede Sendung zollpflichtig.",
|
||||
regionTextEu: "Versand ist in die gesamte Europäische Union möglich — dorthin fällt kein Zoll an. Bestellungen mit einer Lieferadresse außerhalb der EU können wir derzeit leider nicht annehmen. Das gilt auch für Gebiete, die zwar zu einem EU-Land gehören, aber außerhalb des Zollgebiets liegen: Helgoland, Büsingen am Hochrhein, die Kanarischen Inseln, Ceuta und Melilla, die französischen Überseegebiete, Livigno, Campione d’Italia, Åland und den Berg Athos.",
|
||||
paymentHeading: "Zahlungsmethoden",
|
||||
paymentText: "Wir bieten Kreditkarte, SEPA-Lastschrift, Klarna, PayPal, Apple Pay und Google Pay über unseren Zahlungsdienstleister Stripe an. Eine Zahlung per Überweisung ist auf Anfrage möglich.",
|
||||
deliveryTimeHeading: "Lieferzeit",
|
||||
@@ -1380,6 +1383,8 @@ export const de: Dictionary = {
|
||||
streetLabel: "Straße und Hausnummer",
|
||||
zipLabel: "PLZ",
|
||||
cityLabel: "Ort",
|
||||
countryLabel: "Land",
|
||||
shippingRegionDeNote: "Wir versenden derzeit ausschließlich innerhalb Deutschlands.",
|
||||
discountCodeLabel: "Gutscheincode (optional)",
|
||||
discountCodePlaceholder: "z.B. WABEN10",
|
||||
isGiftLabel: "Das ist ein Geschenk",
|
||||
|
||||
@@ -391,6 +391,9 @@ export const en: Dictionary = {
|
||||
tierRange: (min, max, price) => `${min}–${max} jars/items: ${price}`,
|
||||
tierMinimum: (min, price) => `From ${min} jars/items: ${price}`,
|
||||
pickupNote: "Alternatively, you can pick up your order directly from us in Altenmünster by arrangement; no shipping costs apply in that case.",
|
||||
regionHeading: "Shipping region",
|
||||
regionTextDe: "We ship within Germany only. Unfortunately we cannot currently accept orders with a delivery address outside Germany. Heligoland and Büsingen am Hochrhein are excluded as well: both lie outside the EU customs territory, so any shipment there would be subject to customs.",
|
||||
regionTextEu: "We ship to the entire European Union — no customs apply there. Unfortunately we cannot currently accept orders with a delivery address outside the EU. The same applies to territories that belong to an EU country but lie outside the customs territory: Heligoland, Büsingen am Hochrhein, the Canary Islands, Ceuta and Melilla, the French overseas territories, Livigno, Campione d’Italia, Åland and Mount Athos.",
|
||||
paymentHeading: "Payment Methods",
|
||||
paymentText: "We offer credit card, SEPA direct debit, Klarna, PayPal, Apple Pay, and Google Pay via our payment provider Stripe. Payment by bank transfer is available on request.",
|
||||
deliveryTimeHeading: "Delivery Time",
|
||||
@@ -1379,6 +1382,8 @@ export const en: Dictionary = {
|
||||
streetLabel: "Street and house number",
|
||||
zipLabel: "Postal code",
|
||||
cityLabel: "City",
|
||||
countryLabel: "Country",
|
||||
shippingRegionDeNote: "We currently ship within Germany only.",
|
||||
discountCodeLabel: "Discount code (optional)",
|
||||
discountCodePlaceholder: "e.g. WABEN10",
|
||||
isGiftLabel: "This is a gift",
|
||||
|
||||
@@ -226,6 +226,10 @@ export interface Dictionary {
|
||||
tierRange: (min: number, max: number, price: string) => string;
|
||||
tierMinimum: (min: number, price: string) => string;
|
||||
pickupNote: string;
|
||||
/** Liefergebiet — welcher Satz erscheint, steuert der Admin-Bereich. */
|
||||
regionHeading: string;
|
||||
regionTextDe: string;
|
||||
regionTextEu: string;
|
||||
paymentHeading: string;
|
||||
paymentText: string;
|
||||
deliveryTimeHeading: string;
|
||||
@@ -1057,6 +1061,9 @@ export interface Dictionary {
|
||||
streetLabel: string;
|
||||
zipLabel: string;
|
||||
cityLabel: string;
|
||||
countryLabel: string;
|
||||
/** Hinweis neben der Laenderauswahl, wenn nur nach Deutschland geliefert wird. */
|
||||
shippingRegionDeNote: string;
|
||||
discountCodeLabel: string;
|
||||
discountCodePlaceholder: string;
|
||||
isGiftLabel: string;
|
||||
|
||||
@@ -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,143 @@
|
||||
/**
|
||||
* 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,
|
||||
findCustomsExcludedArea,
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findCustomsExcludedArea", () => {
|
||||
test("erkennt die beiden deutschen Gebiete ausserhalb des Zollgebiets", () => {
|
||||
// Der ueberraschende Teil: selbst bei "Nur Deutschland" gibt es
|
||||
// Anschriften, die eine Zollanmeldung braeuchten.
|
||||
expect(findCustomsExcludedArea("DE", "27498")).toBe("Helgoland");
|
||||
expect(findCustomsExcludedArea("DE", "78266")).toBe("Büsingen am Hochrhein");
|
||||
});
|
||||
|
||||
test("laesst normale deutsche Anschriften durch", () => {
|
||||
expect(findCustomsExcludedArea("DE", "86450")).toBeNull();
|
||||
expect(findCustomsExcludedArea("DE", "10115")).toBeNull();
|
||||
});
|
||||
|
||||
test("erkennt spanische Sondergebiete", () => {
|
||||
expect(findCustomsExcludedArea("ES", "51001")).toBe("Ceuta");
|
||||
expect(findCustomsExcludedArea("ES", "52001")).toBe("Melilla");
|
||||
expect(findCustomsExcludedArea("ES", "38001")).toBe("Kanarische Inseln");
|
||||
// Festland Spanien bleibt normaler Binnenversand.
|
||||
expect(findCustomsExcludedArea("ES", "28001")).toBeNull();
|
||||
});
|
||||
|
||||
test("unterscheidet gleiche Ziffern in verschiedenen Laendern", () => {
|
||||
// "22" ist in Finnland Åland, in Italien die Provinz Como. Deshalb ist
|
||||
// die Zuordnung je Land und nicht global.
|
||||
expect(findCustomsExcludedArea("FI", "22100")).toBe("Åland");
|
||||
expect(findCustomsExcludedArea("IT", "22100")).toBeNull();
|
||||
expect(findCustomsExcludedArea("IT", "22061")).toBe("Campione d'Italia");
|
||||
});
|
||||
|
||||
test("erkennt franzoesische Ueberseegebiete", () => {
|
||||
expect(findCustomsExcludedArea("FR", "97100")).toBe("Französische Überseegebiete");
|
||||
expect(findCustomsExcludedArea("FR", "75001")).toBeNull();
|
||||
});
|
||||
|
||||
test("Azoren und Madeira sind KEIN Sondergebiet", () => {
|
||||
// Sie gehoeren voll zum Zoll- und Steuergebiet — normaler Binnenversand.
|
||||
expect(findCustomsExcludedArea("PT", "9500-001")).toBeNull();
|
||||
expect(findCustomsExcludedArea("PT", "9000-001")).toBeNull();
|
||||
});
|
||||
|
||||
test("ist unempfindlich gegen Leerzeichen und Kleinschreibung", () => {
|
||||
expect(findCustomsExcludedArea("de", " 27498 ")).toBe("Helgoland");
|
||||
expect(findCustomsExcludedArea("NL", "1234 ab")).toBeNull();
|
||||
});
|
||||
|
||||
test("ohne Land oder Postleitzahl gibt es nichts zu melden", () => {
|
||||
expect(findCustomsExcludedArea(null, "27498")).toBeNull();
|
||||
expect(findCustomsExcludedArea("DE", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 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"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gebiete, die zu einem EU-Mitgliedstaat gehoeren, aber ausserhalb des
|
||||
* Zoll- und/oder Steuergebiets der Union liegen.
|
||||
*
|
||||
* Die EU ist eine Zollunion — deshalb braucht eine Sendung von Deutschland
|
||||
* nach Frankreich keine Zollanmeldung. Fuer diese Gebiete gilt das NICHT:
|
||||
* dorthin ist jede Sendung eine Ausfuhr mit Zollinhaltserklaerung,
|
||||
* Handelsrechnung und moeglichen Einfuhrabgaben beim Empfaenger. Ein
|
||||
* Versandetikett allein reicht nicht.
|
||||
*
|
||||
* Erkennbar sind sie nur an der Postleitzahl, nicht am Laendercode — und zwei
|
||||
* davon liegen in DEUTSCHLAND. Ohne diese Liste braeuchte also selbst die
|
||||
* Einstellung "Nur Deutschland" gelegentlich eine Zollabwicklung.
|
||||
*
|
||||
* Die Zuordnung ist bewusst je Land, weil dieselbe Ziffernfolge anderswo ein
|
||||
* ganz normales Gebiet bezeichnet: "22" ist in Finnland Åland, in Italien
|
||||
* aber die Provinz Como.
|
||||
*
|
||||
* NICHT enthalten, weil sie voll zum Zoll- UND Steuergebiet gehoeren: die
|
||||
* Azoren und Madeira (Portugal). Dorthin gilt der normale EU-Binnenversand.
|
||||
*/
|
||||
export const CUSTOMS_EXCLUDED_AREAS: {
|
||||
country: string;
|
||||
name: string;
|
||||
/** Vollstaendige Postleitzahlen. */
|
||||
exact?: string[];
|
||||
/** Anfaenge von Postleitzahlen. */
|
||||
prefixes?: string[];
|
||||
}[] = [
|
||||
// Beide liegen ausserhalb des EU-Zollgebiets: Buesingen gehoert zum
|
||||
// Schweizer Zollgebiet, Helgoland zu gar keinem.
|
||||
{ country: "DE", name: "Helgoland", exact: ["27498"] },
|
||||
{ country: "DE", name: "Büsingen am Hochrhein", exact: ["78266"] },
|
||||
// Ceuta und Melilla liegen ausserhalb von Zoll- UND Steuergebiet, die
|
||||
// Kanaren innerhalb des Zoll-, aber ausserhalb des Steuergebiets — beides
|
||||
// bedeutet in der Praxis eine Zollanmeldung.
|
||||
{ country: "ES", name: "Ceuta", prefixes: ["51"] },
|
||||
{ country: "ES", name: "Melilla", prefixes: ["52"] },
|
||||
{ country: "ES", name: "Kanarische Inseln", prefixes: ["35", "38"] },
|
||||
// Livigno liegt ausserhalb des Zollgebiets; Campione d'Italia gehoert seit
|
||||
// 2020 dazu, bleibt aber ausserhalb des Steuergebiets (es gilt Schweizer
|
||||
// Mehrwertsteuer). Exakte Postleitzahlen, weil "23" und "22" in Italien
|
||||
// sonst ganze Provinzen treffen wuerden.
|
||||
{ country: "IT", name: "Livigno", exact: ["23041"] },
|
||||
{ country: "IT", name: "Campione d'Italia", exact: ["22061"] },
|
||||
// Die Übersee-Départements gehoeren zum Zoll-, nicht zum Steuergebiet; die
|
||||
// Übersee-Gebiete (98) gehoeren zu keinem von beiden.
|
||||
{ country: "FR", name: "Französische Überseegebiete", prefixes: ["97", "98"] },
|
||||
// Innerhalb der Zollunion, ausserhalb des Steuergebiets — es gibt eine
|
||||
// Steuergrenze mit Anmeldepflicht.
|
||||
{ country: "FI", name: "Åland", prefixes: ["22"] },
|
||||
{ country: "GR", name: "Berg Athos", exact: ["63086"] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Liegt diese Anschrift in einem Gebiet, das trotz EU-Zugehoerigkeit eine
|
||||
* Zollabwicklung erfordert? Gibt den Namen des Gebiets zurueck, sonst null.
|
||||
*
|
||||
* Leerzeichen und Kleinschreibung werden abgefangen: Postleitzahlen werden in
|
||||
* mehreren EU-Laendern mit Leerzeichen geschrieben (Niederlande "1234 AB"),
|
||||
* und eine Bestellung an einer Schreibweise scheitern zu lassen waere kein
|
||||
* Schutz, sondern ein Fehler.
|
||||
*/
|
||||
export function findCustomsExcludedArea(
|
||||
country: string | null | undefined,
|
||||
zip: string | null | undefined
|
||||
): string | null {
|
||||
const countryCode = country?.trim().toUpperCase();
|
||||
const postalCode = zip?.replace(/\s+/g, "").toUpperCase();
|
||||
if (!countryCode || !postalCode) return null;
|
||||
|
||||
for (const area of CUSTOMS_EXCLUDED_AREAS) {
|
||||
if (area.country !== countryCode) continue;
|
||||
if (area.exact?.includes(postalCode)) return area.name;
|
||||
if (area.prefixes?.some((prefix) => postalCode.startsWith(prefix))) return area.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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