Add DHL checkout integrations (autocomplete, postnummer, return label)

Wires the new backend DHL endpoints into checkout: an address-autocomplete
dropdown on the street fields, live Postnummer validation for Packstation
delivery, and a return-label download link on the order-detail page.
Proxied through Next.js API routes since DHL credentials are tenant-
specific and CheckoutContent is a Client Component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-31 13:01:05 +00:00
parent 51e4860fe3
commit 8a4170a1e6
9 changed files with 338 additions and 22 deletions
+5
View File
@@ -674,6 +674,11 @@ export type CustomerOrderDetail = CustomerOrder & {
correctionInvoiceIssuedAt: string | null;
carrier: string | null;
trackingNumber: string | null;
// Raw media id, not populated — this fetch stays depth=0 (see this
// function's own comment on why), so the order-detail page resolves the
// actual download URL itself via a separate media lookup when present.
dhlReturnLabelMedia: number | null;
dhlReturnTrackingNumber: string | null;
customerFirstName: string;
customerLastName: string;
customerEmail: string;
+21
View File
@@ -200,6 +200,10 @@ export type Product = {
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: string | null;
// Per-product opt-in for a wishlist heart on the homepage spotlight —
// independent of (in addition to) the global wishlistEnabled toggle,
// which still gates the feature site-wide regardless of this flag.
spotlightShowWishlist: boolean;
// Plain booleans, not the raw stock/threshold numbers — the public API
// has no reason to leak exact stock counts, callers only ever need
// "can this be bought right now". `outOfStock` on the product itself
@@ -249,6 +253,7 @@ type PayloadProduct = {
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: { url: string } | number | null;
spotlightShowWishlist: boolean;
trackInventory: boolean;
stock: number | null;
allowBackorder: boolean;
@@ -312,6 +317,7 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
spotlightText: product.spotlightText || null,
spotlightImage:
typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : null,
spotlightShowWishlist: product.spotlightShowWishlist,
outOfStock: isOutOfStock(product.trackInventory, product.stock, product.allowBackorder),
lowStock: isLowStock(product.trackInventory, product.stock, product.lowStockThreshold),
maxQty: maxPurchasableQty(product.trackInventory, product.stock, product.allowBackorder),
@@ -392,6 +398,21 @@ export async function getProductImagesByIds(ids: number[]): Promise<Map<number,
return map;
}
// Resolves a bare media id to its download URL — used by
// /konto/bestellungen/[orderNumber] for order.dhlReturnLabelMedia, which
// stays a raw id on the order fetch itself (that fetch is deliberately
// depth=0, see getCustomerOrderDetail's own comment) rather than bumping
// that fetch's depth just for this one occasional field. `no-store`, not
// ISR-cached like getProductImagesByIds — a return label is a one-off,
// account-specific document, not shared/reusable content worth caching.
export async function getMediaUrlById(id: number): Promise<{ url: string; filename: string } | null> {
const res = await fetch(`${PAYLOAD_URL}/api/media/${id}`, { cache: "no-store" });
if (!res.ok) return null;
const data: { url?: string; filename?: string } = await res.json();
if (!data.url) return null;
return { url: data.url, filename: data.filename ?? "download.pdf" };
}
// Derived from getProducts() (same 60s-ISR-cached fetch every other
// discovery surface already uses) instead of its own separate Payload
// query — also what lets the auto-spotlight rule below just be a plain
+46
View File
@@ -0,0 +1,46 @@
// Thin client for the backend's DHL custom endpoints (src/lib/endpoints/
// dhlValidatePostNumber.ts, dhlAutocompleteAddress.ts) — own copy per repo,
// same "no shared package yet" convention as app/lib/tracking.ts.
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
export async function validateDhlPostNumber(args: {
postNumber: string;
firstName: string;
lastName: string;
}): Promise<{ ok: true; valid: boolean } | { ok: false; reason: string }> {
const params = new URLSearchParams({ tenantSlug: TENANT_SLUG, ...args });
try {
const res = await fetch(`${PAYLOAD_URL}/api/dhl/validate-post-number?${params}`, {
signal: AbortSignal.timeout(8000),
});
const data = await res.json();
if (!res.ok || !data.ok) return { ok: false, reason: data.reason ?? "Postnummer konnte nicht geprüft werden." };
return { ok: true, valid: Boolean(data.valid) };
} catch {
return { ok: false, reason: "Postnummer-Prüfung ist gerade nicht erreichbar." };
}
}
export type DhlAddressSuggestion = {
street: string;
houseNumber?: string;
zip: string;
city: string;
country: string;
};
export async function autocompleteDhlAddress(query: string): Promise<DhlAddressSuggestion[]> {
if (query.trim().length < 3) return [];
const params = new URLSearchParams({ tenantSlug: TENANT_SLUG, query });
try {
const res = await fetch(`${PAYLOAD_URL}/api/dhl/autocomplete-address?${params}`, {
signal: AbortSignal.timeout(5000),
});
const data = await res.json();
if (!res.ok || !data.ok) return [];
return data.suggestions as DhlAddressSuggestion[];
} catch {
return [];
}
}