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> = {
|
const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
|
||||||
text: 1,
|
text: 1,
|
||||||
textarea: 3,
|
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-simple": 4,
|
||||||
"list-titled": 5,
|
"list-titled": 5,
|
||||||
"list-numbered": 6,
|
"list-numbered": 6,
|
||||||
@@ -57,6 +60,28 @@ function FieldEditor({
|
|||||||
if (def.type === "video") {
|
if (def.type === "video") {
|
||||||
return <AdminVideoField name={def.key} label={label} initialValue={row?.valueDe ?? ""} />;
|
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 (
|
return (
|
||||||
<label className="block text-sm">
|
<label className="block text-sm">
|
||||||
<span className="text-ink/70">{label}</span>
|
<span className="text-ink/70">{label}</span>
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ vi.mock("@/lib/rate-limit", () => ({
|
|||||||
getClientIp: vi.fn(async () => "203.0.113.7"),
|
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/bulk-discount", () => ({ ensureBulkDiscountCode: vi.fn(async () => undefined) }));
|
||||||
vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" }));
|
vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" }));
|
||||||
vi.mock("@/i18n/locale", () => ({ getLocale: vi.fn(async () => "de") }));
|
vi.mock("@/i18n/locale", () => ({ getLocale: vi.fn(async () => "de") }));
|
||||||
@@ -108,6 +112,7 @@ beforeEach(() => {
|
|||||||
rateLimit.mockReturnValue(true);
|
rateLimit.mockReturnValue(true);
|
||||||
verifyTurnstileToken.mockResolvedValue(true);
|
verifyTurnstileToken.mockResolvedValue(true);
|
||||||
getProductsByIds.mockResolvedValue([honey]);
|
getProductsByIds.mockResolvedValue([honey]);
|
||||||
|
getShippingRegion.mockResolvedValue("DE");
|
||||||
db.discountCode.findMany.mockResolvedValue([]);
|
db.discountCode.findMany.mockResolvedValue([]);
|
||||||
db.inventory.updateMany.mockResolvedValue({ count: 1 });
|
db.inventory.updateMany.mockResolvedValue({ count: 1 });
|
||||||
db.order.create.mockResolvedValue(ORDER);
|
db.order.create.mockResolvedValue(ORDER);
|
||||||
@@ -177,6 +182,50 @@ describe("Checkout-Route", () => {
|
|||||||
expect(db.order.create).not.toHaveBeenCalled();
|
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 () => {
|
test("bricht ab, wenn der Bestand waehrend des Checkouts nicht mehr reicht", async () => {
|
||||||
// Das bedingte updateMany trifft keine Zeile mehr — jemand anders war
|
// Das bedingte updateMany trifft keine Zeile mehr — jemand anders war
|
||||||
// schneller.
|
// schneller.
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { getCurrentCustomer } from "@/lib/auth";
|
|||||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
import { isTrustedOrigin } from "@/lib/csrf";
|
import { isTrustedOrigin } from "@/lib/csrf";
|
||||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
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 { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
|
||||||
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
|
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
|
||||||
import { releaseUnpaidOrder } from "@/lib/order-release";
|
import { releaseUnpaidOrder } from "@/lib/order-release";
|
||||||
@@ -177,6 +178,42 @@ export async function POST(request: Request) {
|
|||||||
{ status: 400 }
|
{ 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);
|
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 { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
||||||
import { useI18n } from "@/i18n/i18n-provider";
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
import { TurnstileWidget } from "@/components/turnstile-widget";
|
import { TurnstileWidget } from "@/components/turnstile-widget";
|
||||||
|
import { allowedCountryCodes, type ShippingRegion } from "@/lib/shipping-region";
|
||||||
|
|
||||||
type Fulfillment = "VERSAND" | "ABHOLUNG";
|
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. */
|
/** Hausanschrift oder DHL-Packstation — nur bei fulfillment === "VERSAND" relevant. */
|
||||||
type DeliveryTarget = "ADRESSE" | "PACKSTATION";
|
type DeliveryTarget = "ADRESSE" | "PACKSTATION";
|
||||||
|
|
||||||
@@ -42,6 +61,7 @@ export function CheckoutForm({
|
|||||||
bulkDiscountTiers,
|
bulkDiscountTiers,
|
||||||
packstationSearchAvailable = false,
|
packstationSearchAvailable = false,
|
||||||
turnstileSiteKey,
|
turnstileSiteKey,
|
||||||
|
shippingRegion = "DE",
|
||||||
}: {
|
}: {
|
||||||
initialValues?: CheckoutInitialValues;
|
initialValues?: CheckoutInitialValues;
|
||||||
tiers: ShippingTierLike[];
|
tiers: ShippingTierLike[];
|
||||||
@@ -54,6 +74,13 @@ export function CheckoutForm({
|
|||||||
packstationSearchAvailable?: boolean;
|
packstationSearchAvailable?: boolean;
|
||||||
/** Leer, wenn Turnstile nicht konfiguriert ist — dann entfaellt das Widget. */
|
/** Leer, wenn Turnstile nicht konfiguriert ist — dann entfaellt das Widget. */
|
||||||
turnstileSiteKey?: string;
|
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 { t, locale } = useI18n();
|
||||||
const items = useCartStore((s) => s.items);
|
const items = useCartStore((s) => s.items);
|
||||||
@@ -64,6 +91,9 @@ export function CheckoutForm({
|
|||||||
// Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit
|
// Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit
|
||||||
// Token-Rueckgabe statt eines versteckten Feldes.
|
// Token-Rueckgabe statt eines versteckten Feldes.
|
||||||
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
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 [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -157,7 +187,7 @@ export function CheckoutForm({
|
|||||||
city: usesPackstation
|
city: usesPackstation
|
||||||
? selectedPackstation?.city
|
? selectedPackstation?.city
|
||||||
: String(formData.get("city") ?? "") || undefined,
|
: String(formData.get("city") ?? "") || undefined,
|
||||||
country: "DE",
|
country,
|
||||||
packstationNumber: usesPackstation ? selectedPackstation?.number : undefined,
|
packstationNumber: usesPackstation ? selectedPackstation?.number : undefined,
|
||||||
postNumber: usesPackstation
|
postNumber: usesPackstation
|
||||||
? String(formData.get("postNumber") ?? "").trim() || undefined
|
? String(formData.get("postNumber") ?? "").trim() || undefined
|
||||||
@@ -327,6 +357,31 @@ export function CheckoutForm({
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
import { getAddressesForCustomer } from "@/lib/account-data";
|
import { getAddressesForCustomer } from "@/lib/account-data";
|
||||||
import { getShippingTiers } from "@/lib/shipping";
|
import { getShippingTiers, getShippingRegion } from "@/lib/shipping";
|
||||||
import { getBulkDiscountTiers } from "@/lib/bulk-discount";
|
import { getBulkDiscountTiers } from "@/lib/bulk-discount";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -20,7 +20,11 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function CheckoutPage() {
|
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) => ({
|
const tierValues = tiers.map((t) => ({
|
||||||
minQuantity: t.minQuantity,
|
minQuantity: t.minQuantity,
|
||||||
maxQuantity: t.maxQuantity,
|
maxQuantity: t.maxQuantity,
|
||||||
@@ -33,6 +37,7 @@ export default async function CheckoutPage() {
|
|||||||
tiers={tierValues}
|
tiers={tierValues}
|
||||||
bulkDiscountTiers={bulkDiscountTiers}
|
bulkDiscountTiers={bulkDiscountTiers}
|
||||||
packstationSearchAvailable={isPackstationSearchConfigured()}
|
packstationSearchAvailable={isPackstationSearchConfigured()}
|
||||||
|
shippingRegion={shippingRegion}
|
||||||
/>;
|
/>;
|
||||||
|
|
||||||
// getAddressesForCustomer sortiert Standardadresse (isDefault) zuerst, sonst
|
// getAddressesForCustomer sortiert Standardadresse (isDefault) zuerst, sonst
|
||||||
@@ -57,5 +62,6 @@ export default async function CheckoutPage() {
|
|||||||
tiers={tierValues}
|
tiers={tierValues}
|
||||||
bulkDiscountTiers={bulkDiscountTiers}
|
bulkDiscountTiers={bulkDiscountTiers}
|
||||||
packstationSearchAvailable={isPackstationSearchConfigured()}
|
packstationSearchAvailable={isPackstationSearchConfigured()}
|
||||||
|
shippingRegion={shippingRegion}
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { LegalPage } from "@/components/legal-page";
|
import { LegalPage } from "@/components/legal-page";
|
||||||
import { getShippingTiers } from "@/lib/shipping";
|
import { getShippingTiers, getShippingRegion } from "@/lib/shipping";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -14,7 +14,7 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function VersandZahlungPage() {
|
export default async function VersandZahlungPage() {
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const t = getDictionary(locale);
|
const t = getDictionary(locale);
|
||||||
const tiers = await getShippingTiers();
|
const [tiers, region] = await Promise.all([getShippingTiers(), getShippingRegion()]);
|
||||||
|
|
||||||
const tierParagraphs =
|
const tierParagraphs =
|
||||||
tiers.length > 0
|
tiers.length > 0
|
||||||
@@ -41,6 +41,19 @@ export default async function VersandZahlungPage() {
|
|||||||
heading: t.versandZahlungPage.shippingHeading,
|
heading: t.versandZahlungPage.shippingHeading,
|
||||||
paragraphs: [t.versandZahlungPage.shippingIntro, ...tierParagraphs, t.versandZahlungPage.pickupNote],
|
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,
|
heading: t.versandZahlungPage.paymentHeading,
|
||||||
paragraphs: [t.versandZahlungPage.paymentText],
|
paragraphs: [t.versandZahlungPage.paymentText],
|
||||||
|
|||||||
@@ -391,6 +391,9 @@ export const de: Dictionary = {
|
|||||||
tierRange: (min, max, price) => `${min}–${max} Gläser/Artikel: ${price}`,
|
tierRange: (min, max, price) => `${min}–${max} Gläser/Artikel: ${price}`,
|
||||||
tierMinimum: (min, price) => `Ab ${min} Gläsern/Artikeln: ${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.",
|
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",
|
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.",
|
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",
|
deliveryTimeHeading: "Lieferzeit",
|
||||||
@@ -1380,6 +1383,8 @@ export const de: Dictionary = {
|
|||||||
streetLabel: "Straße und Hausnummer",
|
streetLabel: "Straße und Hausnummer",
|
||||||
zipLabel: "PLZ",
|
zipLabel: "PLZ",
|
||||||
cityLabel: "Ort",
|
cityLabel: "Ort",
|
||||||
|
countryLabel: "Land",
|
||||||
|
shippingRegionDeNote: "Wir versenden derzeit ausschließlich innerhalb Deutschlands.",
|
||||||
discountCodeLabel: "Gutscheincode (optional)",
|
discountCodeLabel: "Gutscheincode (optional)",
|
||||||
discountCodePlaceholder: "z.B. WABEN10",
|
discountCodePlaceholder: "z.B. WABEN10",
|
||||||
isGiftLabel: "Das ist ein Geschenk",
|
isGiftLabel: "Das ist ein Geschenk",
|
||||||
|
|||||||
@@ -391,6 +391,9 @@ export const en: Dictionary = {
|
|||||||
tierRange: (min, max, price) => `${min}–${max} jars/items: ${price}`,
|
tierRange: (min, max, price) => `${min}–${max} jars/items: ${price}`,
|
||||||
tierMinimum: (min, price) => `From ${min} 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.",
|
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",
|
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.",
|
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",
|
deliveryTimeHeading: "Delivery Time",
|
||||||
@@ -1379,6 +1382,8 @@ export const en: Dictionary = {
|
|||||||
streetLabel: "Street and house number",
|
streetLabel: "Street and house number",
|
||||||
zipLabel: "Postal code",
|
zipLabel: "Postal code",
|
||||||
cityLabel: "City",
|
cityLabel: "City",
|
||||||
|
countryLabel: "Country",
|
||||||
|
shippingRegionDeNote: "We currently ship within Germany only.",
|
||||||
discountCodeLabel: "Discount code (optional)",
|
discountCodeLabel: "Discount code (optional)",
|
||||||
discountCodePlaceholder: "e.g. WABEN10",
|
discountCodePlaceholder: "e.g. WABEN10",
|
||||||
isGiftLabel: "This is a gift",
|
isGiftLabel: "This is a gift",
|
||||||
|
|||||||
@@ -226,6 +226,10 @@ export interface Dictionary {
|
|||||||
tierRange: (min: number, max: number, price: string) => string;
|
tierRange: (min: number, max: number, price: string) => string;
|
||||||
tierMinimum: (min: number, price: string) => string;
|
tierMinimum: (min: number, price: string) => string;
|
||||||
pickupNote: string;
|
pickupNote: string;
|
||||||
|
/** Liefergebiet — welcher Satz erscheint, steuert der Admin-Bereich. */
|
||||||
|
regionHeading: string;
|
||||||
|
regionTextDe: string;
|
||||||
|
regionTextEu: string;
|
||||||
paymentHeading: string;
|
paymentHeading: string;
|
||||||
paymentText: string;
|
paymentText: string;
|
||||||
deliveryTimeHeading: string;
|
deliveryTimeHeading: string;
|
||||||
@@ -1057,6 +1061,9 @@ export interface Dictionary {
|
|||||||
streetLabel: string;
|
streetLabel: string;
|
||||||
zipLabel: string;
|
zipLabel: string;
|
||||||
cityLabel: string;
|
cityLabel: string;
|
||||||
|
countryLabel: string;
|
||||||
|
/** Hinweis neben der Laenderauswahl, wenn nur nach Deutschland geliefert wird. */
|
||||||
|
shippingRegionDeNote: string;
|
||||||
discountCodeLabel: string;
|
discountCodeLabel: string;
|
||||||
discountCodePlaceholder: string;
|
discountCodePlaceholder: string;
|
||||||
isGiftLabel: string;
|
isGiftLabel: string;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
import { siteConfig } from "@/lib/site-config";
|
import { siteConfig } from "@/lib/site-config";
|
||||||
|
import { DEFAULT_SHIPPING_REGION } from "@/lib/shipping-region";
|
||||||
import type { Locale } from "@/i18n/config";
|
import type { Locale } from "@/i18n/config";
|
||||||
import type { TitledText, LegalSectionContent } from "@/i18n/dictionary-type";
|
import type { TitledText, LegalSectionContent } from "@/i18n/dictionary-type";
|
||||||
|
|
||||||
export type ContentFieldType =
|
export type ContentFieldType =
|
||||||
| "text"
|
| "text"
|
||||||
| "textarea"
|
| "textarea"
|
||||||
|
/** Feste Auswahl statt freiem Text — siehe "options" an ContentFieldDef. */
|
||||||
|
| "select"
|
||||||
| "list-simple"
|
| "list-simple"
|
||||||
| "list-titled"
|
| "list-titled"
|
||||||
| "list-numbered"
|
| "list-numbered"
|
||||||
@@ -23,6 +26,13 @@ export interface ContentFieldDef {
|
|||||||
localized: boolean;
|
localized: boolean;
|
||||||
/** Liefert den aktuellen Fallback-Text im selben Format, das der Parser fuer diesen Typ erwartet. */
|
/** Liefert den aktuellen Fallback-Text im selben Format, das der Parser fuer diesen Typ erwartet. */
|
||||||
getDefault: (locale: Locale) => string;
|
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 {
|
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.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 },
|
{ 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) ---
|
// --- 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.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 },
|
{ 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 { prisma } from "@/lib/prisma";
|
||||||
import { resolveShippingCostCents } from "@/lib/shipping-calc";
|
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";
|
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[]> {
|
export async function getShippingTiers(): Promise<ShippingTier[]> {
|
||||||
return prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } });
|
return prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user