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,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">
|
||||
|
||||
Reference in New Issue
Block a user