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:
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { autocompleteDhlAddress } from "../../../lib/shippingDhl";
|
||||
|
||||
// Proxies the checkout's address-autocomplete input through to Payload's
|
||||
// DHL DataFactory endpoint — same reasoning as validate-dhl-postnumber's
|
||||
// own route: tenant DHL credentials must never reach the browser.
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get("query") ?? "";
|
||||
const suggestions = await autocompleteDhlAddress(query);
|
||||
return NextResponse.json({ ok: true, suggestions });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateDhlPostNumber } from "../../../lib/shippingDhl";
|
||||
|
||||
// Called from CheckoutContent.tsx's Postnummer field blur (Packstation
|
||||
// delivery). Proxied through this Next.js route rather than fetched
|
||||
// directly from the client the way VIES is (see validate-vat/route.ts) —
|
||||
// DHL credentials are tenant-specific and live in Payload, unlike VIES's
|
||||
// public EU endpoint, so the browser must never call Payload's DHL
|
||||
// endpoint (or hold its own copy of tenant credentials) directly.
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const postNumber = typeof body?.postNumber === "string" ? body.postNumber : "";
|
||||
const firstName = typeof body?.firstName === "string" ? body.firstName : "";
|
||||
const lastName = typeof body?.lastName === "string" ? body.lastName : "";
|
||||
if (!postNumber || !firstName || !lastName) {
|
||||
return NextResponse.json({ ok: false, reason: "Postnummer, Vorname und Nachname sind erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await validateDhlPostNumber({ postNumber, firstName, lastName });
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { autocompleteDhlAddress, type DhlAddressSuggestion } from "../../lib/shippingDhl";
|
||||
|
||||
// Wraps a plain street-address input with a DHL DataFactory suggestion
|
||||
// dropdown. Completely invisible/inert when the tenant doesn't have
|
||||
// autocomplete activated (dhl-settings.autocompleteEnabled off) — the proxy
|
||||
// route just returns an empty suggestions array in that case (see
|
||||
// app/api/checkout/autocomplete-address/route.ts), so this degrades to a
|
||||
// plain text input with no dropdown ever appearing, same "no half-built UI
|
||||
// when a feature is off" convention as WishlistButton/searchEnabled.
|
||||
//
|
||||
// Styling duplicates FormField's classes (defined locally in
|
||||
// CheckoutContent.tsx, not exported) rather than importing it, to keep this
|
||||
// component usable on its own.
|
||||
export function AddressAutocomplete({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
onSelectSuggestion,
|
||||
error,
|
||||
placeholder,
|
||||
autoComplete,
|
||||
required,
|
||||
wrapperClassName = "flex-1 min-w-0",
|
||||
}: {
|
||||
label: string;
|
||||
name?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
onSelectSuggestion: (suggestion: DhlAddressSuggestion) => void;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
autoComplete?: string;
|
||||
required?: boolean;
|
||||
wrapperClassName?: string;
|
||||
}) {
|
||||
const [suggestions, setSuggestions] = useState<DhlAddressSuggestion[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (value.trim().length < 3) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const res = await fetch(`/api/checkout/autocomplete-address?query=${encodeURIComponent(value)}`);
|
||||
const data = await res.json().catch(() => ({ ok: false, suggestions: [] }));
|
||||
setSuggestions(data.ok ? data.suggestions : []);
|
||||
}, 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<label className={`relative flex flex-col gap-2 items-start ${wrapperClassName}`}>
|
||||
<span className="text-label text-text-muted">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
// Delayed so a click on a suggestion below (which itself fires a
|
||||
// blur first) still registers before the dropdown unmounts.
|
||||
onBlur={(e) => {
|
||||
setTimeout(() => setOpen(false), 150);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
className={`w-full border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors ${
|
||||
error ? "border-red-600" : "border-border"
|
||||
}`}
|
||||
/>
|
||||
{error && <span className="text-label text-red-600">{error}</span>}
|
||||
{open && suggestions.length > 0 && (
|
||||
<ul className="absolute top-full left-0 right-0 z-10 mt-1 max-h-60 overflow-y-auto rounded-sm border border-border bg-background shadow-lg">
|
||||
{suggestions.map((s, i) => (
|
||||
<li key={i}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(`${s.street}${s.houseNumber ? ` ${s.houseNumber}` : ""}`);
|
||||
onSelectSuggestion(s);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-body-sm text-text-primary hover:bg-brand/10 transition-colors"
|
||||
>
|
||||
{s.street}
|
||||
{s.houseNumber ? ` ${s.houseNumber}` : ""}, {s.zip} {s.city}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { formatPrice } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { CustomSelect } from "../../components/CustomSelect";
|
||||
import { VersandModal } from "../../components/VersandModal";
|
||||
import { AddressAutocomplete } from "./AddressAutocomplete";
|
||||
import { VatBreakdown } from "../../components/VatBreakdown";
|
||||
import { CheckoutSteps } from "../../components/CheckoutSteps";
|
||||
import { ORDER_KEY, PENDING_ORDER_KEY, type OrderSnapshot } from "../../lib/order";
|
||||
@@ -279,6 +280,7 @@ export function CheckoutContent({
|
||||
// this exact same VIES check server-side at submit time regardless —
|
||||
// this state is a preview, never the source of truth.
|
||||
const [vatIdViesStatus, setVatIdViesStatus] = useState<"idle" | "checking" | "valid" | "invalid" | "unavailable">("idle");
|
||||
const [dhlPostNumberStatus, setDhlPostNumberStatus] = useState<"idle" | "checking" | "valid" | "invalid" | "unavailable">("idle");
|
||||
// Flips true only after the hydration effect's setState calls have
|
||||
// actually landed in a render — gates the write-back effect below so it
|
||||
// never fires with the pre-hydration defaults first and briefly
|
||||
@@ -472,6 +474,42 @@ export function CheckoutContent({
|
||||
}
|
||||
}
|
||||
|
||||
// Live-checks the Packstation Postnummer against DHL, mirroring
|
||||
// handleVatIdBlur's shape. `ok: false` from the proxy route covers both
|
||||
// "DHL unreachable" and "this tenant doesn't have Postnummer-validation
|
||||
// activated" (dhl-settings.postnummerEnabled off) — the latter is the
|
||||
// common case for shops without a DHL integration, so it silently falls
|
||||
// back to idle (format-only, already checked above) rather than showing
|
||||
// an alarming "currently unavailable" message for a feature that was
|
||||
// simply never turned on.
|
||||
async function handlePostNumberBlur(e: React.FocusEvent<HTMLInputElement>) {
|
||||
const input = e.target;
|
||||
const value = input.value;
|
||||
const formatError = validatePostNumber(value);
|
||||
setFieldError("shippingPostNumber", formatError, input);
|
||||
if (!value.trim() || formatError || !shippingFirstName.trim() || !shippingLastName.trim()) {
|
||||
setDhlPostNumberStatus("idle");
|
||||
return;
|
||||
}
|
||||
setDhlPostNumberStatus("checking");
|
||||
try {
|
||||
const res = await fetch("/api/checkout/validate-dhl-postnumber", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ postNumber: value, firstName: shippingFirstName, lastName: shippingLastName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
setDhlPostNumberStatus("idle");
|
||||
return;
|
||||
}
|
||||
setDhlPostNumberStatus(data.valid ? "valid" : "invalid");
|
||||
if (!data.valid) input.focus();
|
||||
} catch {
|
||||
setDhlPostNumberStatus("idle");
|
||||
}
|
||||
}
|
||||
|
||||
// Logs into an existing account inline, without leaving /checkout —
|
||||
// router.refresh() re-runs the page's Server Component, which re-reads
|
||||
// the now-set session cookie and passes the resolved customerEmail back
|
||||
@@ -977,13 +1015,16 @@ export function CheckoutContent({
|
||||
Rechnungsadresse (an invoice needs a real postal address).
|
||||
Packstation is only ever offered below, in the optional
|
||||
"Abweichende Lieferadresse" section's own Lieferart toggle. */}
|
||||
<FormField
|
||||
<AddressAutocomplete
|
||||
label="Straße und Hausnummer"
|
||||
name="street"
|
||||
type="text"
|
||||
value={street}
|
||||
onChange={(e) => setStreet(e.target.value)}
|
||||
onChange={setStreet}
|
||||
onBlur={(e) => setFieldError("street", validateRequired("Straße und Hausnummer", e.target.value), e.target)}
|
||||
onSelectSuggestion={(s) => {
|
||||
setZip(s.zip);
|
||||
setCity(s.city);
|
||||
}}
|
||||
error={fieldErrors.street}
|
||||
placeholder="Musterstraße 1"
|
||||
autoComplete="street-address"
|
||||
@@ -1115,12 +1156,15 @@ export function CheckoutContent({
|
||||
</div>
|
||||
</div>
|
||||
{shippingDeliveryMethod === "address" ? (
|
||||
<FormField
|
||||
<AddressAutocomplete
|
||||
label="Straße und Hausnummer"
|
||||
type="text"
|
||||
value={shippingStreet}
|
||||
onChange={(e) => setShippingStreet(e.target.value)}
|
||||
onChange={setShippingStreet}
|
||||
onBlur={(e) => setFieldError("shippingStreet", validateRequired("Straße und Hausnummer", e.target.value), e.target)}
|
||||
onSelectSuggestion={(s) => {
|
||||
setShippingZip(s.zip);
|
||||
setShippingCity(s.city);
|
||||
}}
|
||||
error={fieldErrors.shippingStreet}
|
||||
placeholder="Musterstraße 1"
|
||||
autoComplete="off"
|
||||
@@ -1144,21 +1188,34 @@ export function CheckoutContent({
|
||||
title="Packstationsnummer muss aus 1 bis 3 Ziffern bestehen (1–999)."
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Postnummer"
|
||||
type="text"
|
||||
value={shippingPostNumber}
|
||||
onChange={(e) => setShippingPostNumber(e.target.value)}
|
||||
onBlur={(e) => setFieldError("shippingPostNumber", validatePostNumber(e.target.value), e.target)}
|
||||
error={fieldErrors.shippingPostNumber}
|
||||
inputMode="numeric"
|
||||
placeholder="1234567890"
|
||||
autoComplete="off"
|
||||
pattern="\d{6,10}"
|
||||
maxLength={10}
|
||||
title="Postnummer muss aus 6 bis 10 Ziffern bestehen."
|
||||
required
|
||||
/>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<FormField
|
||||
label="Postnummer"
|
||||
type="text"
|
||||
value={shippingPostNumber}
|
||||
onChange={(e) => setShippingPostNumber(e.target.value)}
|
||||
onBlur={handlePostNumberBlur}
|
||||
error={fieldErrors.shippingPostNumber}
|
||||
inputMode="numeric"
|
||||
placeholder="1234567890"
|
||||
autoComplete="off"
|
||||
pattern="\d{6,10}"
|
||||
maxLength={10}
|
||||
title="Postnummer muss aus 6 bis 10 Ziffern bestehen."
|
||||
required
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
{/* Silent when idle — covers both "not yet checked" and
|
||||
"this shop has no DHL Postnummer-validation active",
|
||||
same reasoning as handlePostNumberBlur's own comment. */}
|
||||
<p className="text-label text-text-muted min-h-[1.05rem]">
|
||||
{dhlPostNumberStatus === "checking" && "Postnummer wird bei DHL geprüft…"}
|
||||
{dhlPostNumberStatus === "valid" && <span className="text-success">✓ Postnummer bestätigt</span>}
|
||||
{dhlPostNumberStatus === "invalid" && (
|
||||
<span className="text-red-600">DHL konnte Postnummer/Name-Kombination nicht bestätigen.</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Footer } from "../../../components/Footer";
|
||||
import { VatBreakdown } from "../../../components/VatBreakdown";
|
||||
import { formatPrice, formatDate } from "../../../lib/format";
|
||||
import { getSessionCustomer, getCustomerOrderDetail, customerOrderAction } from "../../../lib/customerAuth";
|
||||
import { getProductImagesByIds, getPaymentMethods, groupPaymentMethodsForCheckout } from "../../../lib/payload";
|
||||
import { getProductImagesByIds, getPaymentMethods, groupPaymentMethodsForCheckout, getMediaUrlById } from "../../../lib/payload";
|
||||
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { buildTrackingUrl, CARRIER_LABELS } from "../../../lib/tracking";
|
||||
import { OrderActionButton } from "./components/OrderActionButton";
|
||||
@@ -48,6 +48,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
: order.shippingStreet;
|
||||
const action = customerOrderAction(order.status);
|
||||
const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
|
||||
const dhlReturnLabel = order.dhlReturnLabelMedia ? await getMediaUrlById(order.dhlReturnLabelMedia) : null;
|
||||
const taxBreakdown = computeTaxBreakdown(order.items, order.subtotal, order.discountAmount, order.shippingCost);
|
||||
// Same "Online-Zahlung" grouping/eligibility the switch-to-stripe route
|
||||
// itself re-checks authoritatively — only offer the button when it
|
||||
@@ -105,6 +106,15 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dhlReturnLabel && (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="text-label text-text-muted">DHL-Retourenschein{order.dhlReturnTrackingNumber ? ` (${order.dhlReturnTrackingNumber})` : ""}</p>
|
||||
<a href={dhlReturnLabel.url} target="_blank" rel="noopener noreferrer" className="text-body-sm text-brand hover:underline">
|
||||
Retourenschein herunterladen
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
{/* Labeled "Rechnungsadresse" only once there's an actual
|
||||
second (shipping) address to distinguish it from — the
|
||||
|
||||
@@ -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