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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user