From 8a4170a1e63d7704c92e6062f5dcf7461b09d3a7 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 31 Jul 2026 13:01:05 +0000 Subject: [PATCH] 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 --- README.md | 32 +++++ .../checkout/autocomplete-address/route.ts | 12 ++ .../checkout/validate-dhl-postnumber/route.ts | 21 ++++ .../components/AddressAutocomplete.tsx | 112 ++++++++++++++++++ app/checkout/components/CheckoutContent.tsx | 99 ++++++++++++---- app/konto/bestellungen/[orderNumber]/page.tsx | 12 +- app/lib/customerAuth.ts | 5 + app/lib/payload.ts | 21 ++++ app/lib/shippingDhl.ts | 46 +++++++ 9 files changed, 338 insertions(+), 22 deletions(-) create mode 100644 app/api/checkout/autocomplete-address/route.ts create mode 100644 app/api/checkout/validate-dhl-postnumber/route.ts create mode 100644 app/checkout/components/AddressAutocomplete.tsx create mode 100644 app/lib/shippingDhl.ts diff --git a/README.md b/README.md index 4a47b68..ced9ff1 100644 --- a/README.md +++ b/README.md @@ -2313,6 +2313,38 @@ features requires the matching `CompanySettings` toggle to actually be switched on before it shows up anywhere in the frontend — if a feature "isn't showing," check that first. +## DHL checkout integrations (2026-07-31) + +Three checkout-facing pieces added, each invisible unless the tenant's +backend `dhl-settings` has the matching toggle on (checked indirectly — +the calls below 404 gracefully when a tenant hasn't activated a given DHL +sub-integration, same "no half-built UI" convention as the feature +toggles above). Full backend-side documentation: +`docker/payload`'s `src/lib/shipping/README.md`. + +- **`app/lib/shippingDhl.ts`** — thin client for the backend's 2 public + DHL endpoints (Postnummer validation, DataFactory address autocomplete). + Own copy, no shared package — same convention as `app/lib/tracking.ts`. +- **`app/api/checkout/validate-dhl-postnumber/route.ts`** and + **`app/api/checkout/autocomplete-address/route.ts`** — Next.js API + routes proxying to the backend. Required because `CheckoutContent.tsx` + is a Client Component and DHL credentials are tenant-specific (live in + Payload) — unlike VIES (`validate-vat/route.ts`), the browser can never + call Payload's DHL endpoint directly. +- **`app/checkout/components/AddressAutocomplete.tsx`** — wraps the + billing and shipping street inputs with a debounced DHL DataFactory + suggestion dropdown; selecting a suggestion also fills zip/city. +- **Postnummer live validation** — `CheckoutContent.tsx`'s + `handlePostNumberBlur` (mirrors `handleVatIdBlur`'s shape/status-state + pattern) checks the Packstation Postnummer against DHL on blur, once the + existing format check (6–10 digits) already passes. +- **Return-label download** — `/konto/bestellungen/[orderNumber]` shows + a "Retourenschein herunterladen" link once the backend has generated a + DHL return label for that order (`order.dhlReturnLabelMedia`, resolved + via the new `getMediaUrlById()` in `app/lib/payload.ts` — the order + fetch itself stays `depth=0` for unrelated reasons, see + `getCustomerOrderDetail`'s own comment). + ## Related design source - `~/dev/einfach-produktiv/mockups/` — Figma-stage mockup PNGs diff --git a/app/api/checkout/autocomplete-address/route.ts b/app/api/checkout/autocomplete-address/route.ts new file mode 100644 index 0000000..bc8e6e2 --- /dev/null +++ b/app/api/checkout/autocomplete-address/route.ts @@ -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 }); +} diff --git a/app/api/checkout/validate-dhl-postnumber/route.ts b/app/api/checkout/validate-dhl-postnumber/route.ts new file mode 100644 index 0000000..a1456e3 --- /dev/null +++ b/app/api/checkout/validate-dhl-postnumber/route.ts @@ -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); +} diff --git a/app/checkout/components/AddressAutocomplete.tsx b/app/checkout/components/AddressAutocomplete.tsx new file mode 100644 index 0000000..49d842e --- /dev/null +++ b/app/checkout/components/AddressAutocomplete.tsx @@ -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) => void; + onSelectSuggestion: (suggestion: DhlAddressSuggestion) => void; + error?: string; + placeholder?: string; + autoComplete?: string; + required?: boolean; + wrapperClassName?: string; +}) { + const [suggestions, setSuggestions] = useState([]); + const [open, setOpen] = useState(false); + const debounceRef = useRef | 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 ( + + ); +} diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx index 25b9eb1..5f59742 100644 --- a/app/checkout/components/CheckoutContent.tsx +++ b/app/checkout/components/CheckoutContent.tsx @@ -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) { + 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. */} - 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({ {shippingDeliveryMethod === "address" ? ( - 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 /> - 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 - /> +
+ 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. */} +

+ {dhlPostNumberStatus === "checking" && "Postnummer wird bei DHL geprüft…"} + {dhlPostNumberStatus === "valid" && ✓ Postnummer bestätigt} + {dhlPostNumberStatus === "invalid" && ( + DHL konnte Postnummer/Name-Kombination nicht bestätigen. + )} +

+
)}
diff --git a/app/konto/bestellungen/[orderNumber]/page.tsx b/app/konto/bestellungen/[orderNumber]/page.tsx index 961a915..f35fa19 100644 --- a/app/konto/bestellungen/[orderNumber]/page.tsx +++ b/app/konto/bestellungen/[orderNumber]/page.tsx @@ -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
)} + {dhlReturnLabel && ( +
+

DHL-Retourenschein{order.dhlReturnTrackingNumber ? ` (${order.dhlReturnTrackingNumber})` : ""}

+ + Retourenschein herunterladen + +
+ )} +
{/* Labeled "Rechnungsadresse" only once there's an actual second (shipping) address to distinguish it from — the diff --git a/app/lib/customerAuth.ts b/app/lib/customerAuth.ts index e654ec7..9e61412 100644 --- a/app/lib/customerAuth.ts +++ b/app/lib/customerAuth.ts @@ -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; diff --git a/app/lib/payload.ts b/app/lib/payload.ts index a51bc6b..da39477 100644 --- a/app/lib/payload.ts +++ b/app/lib/payload.ts @@ -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 { + 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 diff --git a/app/lib/shippingDhl.ts b/app/lib/shippingDhl.ts new file mode 100644 index 0000000..e8897f7 --- /dev/null +++ b/app/lib/shippingDhl.ts @@ -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 { + 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 []; + } +}