Feed checkout's country selects from Payload instead of a hardcoded list

Both the billing and shipping-override country selects, plus PLZ
maxLength/pattern validation, now read from the new shipping-countries
collection (getShippingCountries()) rather than a hardcoded
Deutschland/Österreich(/Schweiz) array. Lets an admin add or reorder
destination countries without a frontend deploy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-23 20:21:13 +00:00
parent 50db2fcb4b
commit 47d03dd61b
3 changed files with 78 additions and 26 deletions
+34 -24
View File
@@ -19,19 +19,20 @@ import { dispatchAuthChanged } from "../../lib/auth";
import { readCheckoutDraft, writeCheckoutDraft, clearCheckoutDraft } from "../../lib/checkoutDraft";
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
import { computeExemptTotals, destinationCountry, isExemptionEligibleCountry } from "../../lib/vatExemption";
import type { ShippingMethod, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload";
import type { ShippingMethod, ShippingCountry, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload";
import type { CustomerProfile } from "../../lib/customerAuth";
// Native HTML5 pattern validation (instant, no round-trip) mirroring the
// same rules Orders.ts/Customers.ts enforce server-side — a plausibility
// check, not the source of truth (the backend re-validates regardless of
// what a customer's browser did or didn't catch). Only Germany/Austria/
// Switzerland are offered in the country <select>, so a fixed 3-way
// lookup is enough — unlike the backend's own zip check, which stays
// free-text-country-tolerant since Payload's admin has no such select.
const PLZ_DIGITS: Record<string, number> = { Deutschland: 5, Österreich: 4, Schweiz: 4 };
function plzPattern(country: string): string {
const digits = PLZ_DIGITS[country] ?? 4;
// what a customer's browser did or didn't catch). `plzDigitsMap` comes
// from Payload's shipping-countries collection (see lib/payload.ts's
// getShippingCountries()) — which countries are offered, and how many PLZ
// digits each expects, is admin-configurable now, not a hardcoded array
// here. `?? 4` only matters if a country somehow isn't in the map at all
// (shouldn't happen — the <select> options are built from the same list).
function plzPattern(country: string, plzDigitsMap: Record<string, number>): string {
const digits = plzDigitsMap[country] ?? 4;
return `\\d{${digits}}`;
}
@@ -48,9 +49,9 @@ function validateEmailFormat(value: string): string {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? "" : "Bitte eine gültige E-Mail-Adresse angeben.";
}
function validateZip(value: string, country: string): string {
function validateZip(value: string, country: string, plzDigitsMap: Record<string, number>): string {
if (!value.trim()) return "PLZ ist erforderlich.";
const digits = PLZ_DIGITS[country] ?? 4;
const digits = plzDigitsMap[country] ?? 4;
return new RegExp(`^\\d{${digits}}$`).test(value) ? "" : `PLZ muss aus ${digits} Ziffern bestehen.`;
}
@@ -95,6 +96,7 @@ function FormField({
export function CheckoutContent({
shippingMethods,
shippingCountries,
paymentMethods,
trustBadges,
shippingSettings,
@@ -103,6 +105,10 @@ export function CheckoutContent({
savedProfile,
}: {
shippingMethods: ShippingMethod[];
/** Which countries the "Land" <select>s offer, and each one's PLZ digit
* count — admin-configurable (Payload's shipping-countries collection),
* not a hardcoded array here anymore. */
shippingCountries: ShippingCountry[];
paymentMethods: PaymentMethod[];
trustBadges: TrustBadge[];
/** Delivery-time disclosure (Payload's Shipping Settings) — named
@@ -124,6 +130,10 @@ export function CheckoutContent({
const cart = useCart();
const products = useProducts();
const discount = useDiscount();
// { "Deutschland": 5, "Österreich": 4, ... } — built once from the
// fetched list rather than re-deriving it inline at every plzPattern()/
// validateZip() call site.
const plzDigitsMap: Record<string, number> = Object.fromEntries(shippingCountries.map((c) => [c.name, c.plzDigits]));
const [shippingMethodId, setShippingMethodId] = useState<number | null>(shippingMethods[0]?.id ?? null);
const [paymentMethodId, setPaymentMethodId] = useState<number | null>(paymentMethods[0]?.id ?? null);
const [versandOpen, setVersandOpen] = useState(false);
@@ -828,14 +838,14 @@ export function CheckoutContent({
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
onBlur={(e) => setFieldError("zip", validateZip(e.target.value, country), e.target)}
onBlur={(e) => setFieldError("zip", validateZip(e.target.value, country, plzDigitsMap), e.target)}
error={fieldErrors.zip}
placeholder="10115"
autoComplete="postal-code"
inputMode="numeric"
pattern={plzPattern(country)}
maxLength={PLZ_DIGITS[country] ?? 4}
title={`PLZ muss aus ${PLZ_DIGITS[country] ?? 4} Ziffern bestehen.`}
pattern={plzPattern(country, plzDigitsMap)}
maxLength={plzDigitsMap[country] ?? 4}
title={`PLZ muss aus ${plzDigitsMap[country] ?? 4} Ziffern bestehen.`}
required
/>
<FormField
@@ -860,9 +870,9 @@ export function CheckoutContent({
required
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
>
<option>Deutschland</option>
<option>Österreich</option>
<option>Schweiz</option>
{shippingCountries.map((c) => (
<option key={c.name}>{c.name}</option>
))}
</select>
</label>
@@ -987,14 +997,14 @@ export function CheckoutContent({
type="text"
value={shippingZip}
onChange={(e) => setShippingZip(e.target.value)}
onBlur={(e) => setFieldError("shippingZip", validateZip(e.target.value, shippingCountry), e.target)}
onBlur={(e) => setFieldError("shippingZip", validateZip(e.target.value, shippingCountry, plzDigitsMap), e.target)}
error={fieldErrors.shippingZip}
placeholder="10115"
autoComplete="off"
inputMode="numeric"
pattern={plzPattern(shippingCountry)}
maxLength={PLZ_DIGITS[shippingCountry] ?? 4}
title={`PLZ muss aus ${PLZ_DIGITS[shippingCountry] ?? 4} Ziffern bestehen.`}
pattern={plzPattern(shippingCountry, plzDigitsMap)}
maxLength={plzDigitsMap[shippingCountry] ?? 4}
title={`PLZ muss aus ${plzDigitsMap[shippingCountry] ?? 4} Ziffern bestehen.`}
required
/>
<FormField
@@ -1017,9 +1027,9 @@ export function CheckoutContent({
required
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
>
<option>Deutschland</option>
<option>Österreich</option>
<option>Schweiz</option>
{shippingCountries.map((c) => (
<option key={c.name}>{c.name}</option>
))}
</select>
</label>
</div>
+4 -2
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { CheckoutContent } from "./components/CheckoutContent";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
import { getShippingMethods, getPaymentMethods, getCartTrustBadges, getShippingSettings, getDefaultTaxRatePercent } from "../lib/payload";
import { getShippingMethods, getShippingCountries, getPaymentMethods, getCartTrustBadges, getShippingSettings, getDefaultTaxRatePercent } from "../lib/payload";
import { getSessionCustomer, getCustomerProfile } from "../lib/customerAuth";
// robots: noindex — transactional page, same reasoning as /cart.
@@ -16,8 +16,9 @@ export const metadata: Metadata = {
};
export default async function CheckoutPage() {
const [shippingMethods, paymentMethods, trustBadges, shippingSettings, defaultTaxRate, session] = await Promise.all([
const [shippingMethods, shippingCountries, paymentMethods, trustBadges, shippingSettings, defaultTaxRate, session] = await Promise.all([
getShippingMethods(),
getShippingCountries(),
getPaymentMethods(),
getCartTrustBadges(),
getShippingSettings(),
@@ -33,6 +34,7 @@ export default async function CheckoutPage() {
<main className="flex flex-col flex-1 bg-bg-base">
<CheckoutContent
shippingMethods={shippingMethods}
shippingCountries={shippingCountries}
paymentMethods={paymentMethods}
trustBadges={trustBadges}
shippingSettings={shippingSettings}
+40
View File
@@ -471,6 +471,46 @@ export async function getShippingMethods(): Promise<ShippingMethod[]> {
}));
}
// Feeds /checkout's "Land" <select> (both the billing address and the
// optional shipping-address override) and its PLZ digit-count validation
// — previously a hardcoded array + PLZ_DIGITS map in CheckoutContent.tsx
// itself. Which countries are actually deliverable can now change without
// a code deploy (e.g. temporarily dropping Schweiz — no customs/export-
// invoice handling exists for it yet). Deliberately unrelated to VAT-
// exemption eligibility (lib/vatExemption.ts's isExemptionEligibleCountry(),
// still hardcoded to "Österreich") — that's a legal/tax-law question, not
// a shipping-logistics one, and stays in code on purpose.
export type ShippingCountry = {
name: string;
plzDigits: number;
};
type PayloadShippingCountry = ShippingCountry & { active: boolean };
export async function getShippingCountries(): Promise<ShippingCountry[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[active][equals]": "true",
sort: "sortOrder",
limit: "20",
});
const res = await fetch(`${PAYLOAD_URL}/api/shipping-countries?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getShippingCountries: Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: PayloadShippingCountry[] } = await res.json();
const docs = Array.isArray(data.docs) ? data.docs : [];
return docs.map((doc) => ({
name: doc.name,
plzDigits: doc.plzDigits,
}));
}
export type ShippingSettings = {
handlingDays: { min: number; max: number };
transitDays: { min: number; max: number };