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
@@ -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>
);
}
+78 -21
View File
@@ -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 (1999)."
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">