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