feat: Packstation ueber DHL-Standortsuche als Lieferziel waehlbar
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m8s

Im Checkout laesst sich unter "Versand" jetzt zwischen der eigenen
Anschrift und einer DHL-Packstation waehlen. Postleitzahl eingeben,
suchen, Packstation aus der Trefferliste anklicken, DHL-Postnummer
eintragen — die Auswahl wird an der Bestellung gespeichert.

Aufbau:

- lib/dhl-locations.ts: Client fuer die DHL Location Finder Unified API
  (GET /find-by-address). Bewusst getrennt von lib/dhl.ts — das ist ein
  anderer DHL-Dienst mit anderer Authentifizierung: nur der API-Key im
  Header, kein OAuth und kein Geschaeftskundenvertrag. Die
  Packstation-Auswahl funktioniert damit auch, solange die
  Versandmarken-Erstellung noch nicht eingerichtet ist.
- api/shop/packstationen: serverseitiger Zwischenschritt, damit der
  DHL-API-Key nicht ins Browser-Bundle geraet. Streng rate-limitiert
  (15/Minute statt 60 wie die Produktsuche) und mit 15-Minuten-Cache:
  dahinter steht ein FREMDES Kontingent von 500 Aufrufen pro Tag, das ein
  ungebremstes Suchfeld an einem Nachmittag aufbrauchen koennte.
- Order/Address: packstationNumber + postNumber (Migration
  20260915222938_add_packstation_delivery, beide nullable).
- checkoutSchema: Postnummer als 6-10 Ziffern, plus zwei refine-Regeln —
  ohne Postnummer keine Packstation (DHL verlangt sie zwingend), und eine
  Packstation nur bei Versand, nicht bei Abholung.

Zwei Entscheidungen, die Folgearbeit ersparen:

- Bei Packstation-Auswahl tragen street/zip/city zusaetzlich die lesbare
  Anschrift ("Packstation 145", PLZ, Ort). Dadurch zeigen Rechnung,
  Bestaetigungsmail, Sendungsverfolgung und Admin-Ansicht ohne jede
  Aenderung etwas Sinnvolles; packstationNumber/postNumber tragen daneben
  das, was DHL technisch braucht.
- lib/dhl.ts baut daraus einen Locker-Empfaenger statt einer
  Hausanschrift. DHL kennt fuer den Empfaenger zwei sich ausschliessende
  Formen; eine Packstation als Strassentext zu schicken ergaebe eine Marke
  auf eine Anschrift, die es nicht gibt.

Die Option erscheint nur, wenn DHL_API_KEY gesetzt ist — sonst wuerde
Kundschaft in eine Suche laufen, die immer fehlschlaegt.

Geprueft: sechs Tests fuer das Umsetzen der DHL-Antwort (die
Packstation-Nummer steht in location.keywordId, NICHT in
location.ids[0].locationId — letzteres ist ein Systemcode, mit dem weder
Kundschaft noch DHL etwas anfangen kann). Dazu am laufenden
Produktions-Build: ohne Key liefert der Endpunkt 503 und der Checkout
zeigt die Option gar nicht; mit Key 400 bei ungueltiger PLZ und ein echter
Aufruf an api.dhl.com, der mit 401 quittiert wird — URL, Header und
Anfrageformat stimmen also. Im Browser durchgeklickt: Umschalten blendet
das Strassenfeld aus und Postnummer samt Suche ein, die Suche ruft die
Route und zeigt die Fehlermeldung an.

Feldnamen beider DHL-APIs vorab gegen developer.dhl.com geprueft. Ein
Lauf gegen einen echten Schluessel steht noch aus.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-16 00:37:00 +02:00
1 parent ff365c3cb1
commit 9dd71ffb9a
16 files changed
+757 -8

No files matched your search

+4
View File
@@ -107,6 +107,10 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
street: order.street,
zip: order.zip,
city: order.city,
// Bei gesetzter Packstation-Nummer baut lib/dhl.ts daraus einen
// Locker-Empfaenger statt einer Hausanschrift.
packstationNumber: order.packstationNumber,
postNumber: order.postNumber,
country: order.country,
phone: order.phone,
email: order.email,
+8
View File
@@ -93,6 +93,14 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
<>
<p>{order.street}</p>
<p>{order.zip} {order.city}</p>
{/* Nur bei Packstation-Lieferung: ohne die Postnummer laesst
sich keine Versandmarke erstellen, und ohne Anzeige hier
waere nicht nachvollziehbar, warum DHL sie ablehnt. */}
{order.packstationNumber ? (
<p className="mt-1 text-ink/70">
{t.adminOrderDetailPage.postNumberLabel}: {order.postNumber ?? "—"}
</p>
) : null}
<p className="mt-2 text-xs text-ink/65">
{order.shipment?.shippedAt
? t.adminOrderDetailPage.shippedOn(formatDate(order.shipment.shippedAt))
+7
View File
@@ -250,6 +250,13 @@ export async function POST(request: Request) {
zip: input.zip ?? "",
city: input.city ?? "",
country: input.country,
// Bei Packstation-Lieferung tragen street/zip/city zusaetzlich die
// lesbare Anschrift der Packstation (siehe checkout-form.tsx),
// damit Rechnung, E-Mails und Admin-Ansicht unveraendert etwas
// Sinnvolles zeigen. Diese beiden Felder sind das, was DHL
// technisch braucht.
packstationNumber: input.packstationNumber || null,
postNumber: input.postNumber || null,
subtotalCents,
shippingCents,
discountCents,
+76
View File
@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import {
findPackstations,
isPackstationSearchConfigured,
PackstationApiError,
PackstationNotConfiguredError,
} from "@/lib/dhl-locations";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
const RESULT_LIMIT = 10;
// Deutlich strenger als die Produktsuche (60/Minute): hinter diesem Endpunkt
// steht ein FREMDES Kontingent von 500 Aufrufen pro Tag (siehe
// lib/dhl-locations.ts). Ein ungebremstes Suchfeld koennte es an einem
// Nachmittag aufbrauchen und die Packstation-Auswahl damit fuer alle
// anderen Kund:innen bis zum naechsten Tag lahmlegen. Die Suche wird
// ausserdem erst auf Knopfdruck ausgeloest, nicht bei jedem Tastenanschlag.
const SEARCH_ATTEMPTS_PER_WINDOW = 15;
const SEARCH_WINDOW_MS = 60 * 1000;
const POSTAL_CODE_PATTERN = /^\d{5}$/;
/**
* Sucht Packstationen zu einer Postleitzahl fuer die Auswahl im Checkout.
*
* Bewusst ein serverseitiger Zwischenschritt statt eines direkten Aufrufs aus
* dem Browser: der DHL-API-Key darf nicht ins Frontend-Bundle gelangen, und
* nur so greifen Rate-Limit und der Cache aus lib/dhl-locations.ts
* ueberhaupt.
*/
export async function GET(request: Request) {
const ip = await getClientIp();
if (!rateLimit(`packstation-search:${ip}`, SEARCH_ATTEMPTS_PER_WINDOW, SEARCH_WINDOW_MS)) {
return NextResponse.json(
{ error: "Zu viele Suchanfragen. Bitte einen Moment warten." },
{ status: 429 }
);
}
// Getrennt von einem Fehlerfall: ohne DHL-Konfiguration soll die
// Oberflaeche die Packstation-Option gar nicht erst anbieten, statt eine
// Fehlermeldung zu zeigen.
if (!isPackstationSearchConfigured()) {
return NextResponse.json(
{ error: "Die Packstation-Suche ist nicht konfiguriert.", configured: false },
{ status: 503 }
);
}
const params = new URL(request.url).searchParams;
const postalCode = params.get("plz")?.trim() ?? "";
const city = params.get("ort")?.trim() || undefined;
if (!POSTAL_CODE_PATTERN.test(postalCode)) {
return NextResponse.json(
{ error: "Bitte eine fünfstellige Postleitzahl eingeben." },
{ status: 400 }
);
}
try {
const packstationen = await findPackstations({ postalCode, city, limit: RESULT_LIMIT });
return NextResponse.json({ packstationen });
} catch (error: unknown) {
if (error instanceof PackstationNotConfiguredError) {
return NextResponse.json({ error: error.message, configured: false }, { status: 503 });
}
if (error instanceof PackstationApiError) {
// Die Meldung stammt aus dem eigenen Client und ist bewusst allgemein
// gehalten — sie gibt weder Schluessel noch interne URLs preis.
console.error("Packstation-Suche fehlgeschlagen", error);
return NextResponse.json({ error: error.message }, { status: 502 });
}
throw error;
}
}
+198 -4
View File
@@ -13,6 +13,19 @@ import { useI18n } from "@/i18n/i18n-provider";
type Fulfillment = "VERSAND" | "ABHOLUNG";
/** Hausanschrift oder DHL-Packstation — nur bei fulfillment === "VERSAND" relevant. */
type DeliveryTarget = "ADRESSE" | "PACKSTATION";
/** Ein Treffer der DHL-Standortsuche, wie ihn /api/shop/packstationen liefert. */
interface Packstation {
number: string;
name: string;
street: string;
zip: string;
city: string;
distanceMeters: number | null;
}
export interface CheckoutInitialValues {
firstName: string;
lastName: string;
@@ -27,10 +40,17 @@ export function CheckoutForm({
initialValues,
tiers,
bulkDiscountTiers,
packstationSearchAvailable = false,
}: {
initialValues?: CheckoutInitialValues;
tiers: ShippingTierLike[];
bulkDiscountTiers: BulkDiscountTierLike[];
/**
* Nur wenn DHL_API_KEY gesetzt ist, kann die Standortsuche ueberhaupt
* antworten — sonst wird die Packstation-Option gar nicht erst angeboten,
* statt Kundschaft in eine Suche laufen zu lassen, die immer fehlschlaegt.
*/
packstationSearchAvailable?: boolean;
}) {
const { t, locale } = useI18n();
const items = useCartStore((s) => s.items);
@@ -43,6 +63,37 @@ export function CheckoutForm({
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isGift, setIsGift] = useState(false);
const [deliveryTarget, setDeliveryTarget] = useState<DeliveryTarget>("ADRESSE");
const [packstationZip, setPackstationZip] = useState("");
const [packstationResults, setPackstationResults] = useState<Packstation[] | null>(null);
const [packstationSearching, setPackstationSearching] = useState(false);
const [packstationError, setPackstationError] = useState<string | null>(null);
const [selectedPackstation, setSelectedPackstation] = useState<Packstation | null>(null);
const usesPackstation = fulfillment === "VERSAND" && deliveryTarget === "PACKSTATION";
async function searchPackstations() {
setPackstationSearching(true);
setPackstationError(null);
setPackstationResults(null);
try {
const response = await fetch(
`/api/shop/packstationen?plz=${encodeURIComponent(packstationZip.trim())}`
);
const data = await response.json();
if (!response.ok) {
setPackstationError(typeof data.error === "string" ? data.error : t.checkoutPage.packstationNoResults);
return;
}
setPackstationResults(Array.isArray(data.packstationen) ? data.packstationen : []);
} catch {
// fetch() wirft nur bei Netzwerkabbruch — ein Fehlerstatus landet oben.
setPackstationError(t.checkoutPage.packstationNoResults);
} finally {
setPackstationSearching(false);
}
}
const errorRef = useRef<HTMLParagraphElement>(null);
// Fokus aktiv auf die Fehlermeldung verschieben statt sie nur farblich
@@ -67,6 +118,14 @@ export function CheckoutForm({
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
// Vor dem Absenden abfangen, statt den Server einen 400er zurueckgeben zu
// lassen: ohne Auswahl gaebe es keine Lieferadresse.
if (usesPackstation && !selectedPackstation) {
setError(t.checkoutPage.packstationChooseHint);
return;
}
setSubmitting(true);
setError(null);
@@ -77,10 +136,27 @@ export function CheckoutForm({
lastName: String(formData.get("lastName") ?? ""),
phone: String(formData.get("phone") ?? "") || undefined,
fulfillmentMethod: fulfillment,
street: String(formData.get("street") ?? "") || undefined,
zip: String(formData.get("zip") ?? "") || undefined,
city: String(formData.get("city") ?? "") || undefined,
// Bei einer Packstation kommen Anschriftsfelder nicht aus dem Formular,
// sondern aus dem gewaehlten Standort. "street" traegt dabei bewusst den
// lesbaren Packstation-Namen, damit Rechnung, Bestaetigungsmail und
// Admin-Ansicht unveraendert etwas Sinnvolles zeigen; die technisch
// noetigen Werte stehen in packstationNumber/postNumber.
street: usesPackstation
? selectedPackstation
? `Packstation ${selectedPackstation.number}`
: undefined
: String(formData.get("street") ?? "") || undefined,
zip: usesPackstation
? selectedPackstation?.zip
: String(formData.get("zip") ?? "") || undefined,
city: usesPackstation
? selectedPackstation?.city
: String(formData.get("city") ?? "") || undefined,
country: "DE",
packstationNumber: usesPackstation ? selectedPackstation?.number : undefined,
postNumber: usesPackstation
? String(formData.get("postNumber") ?? "").trim() || undefined
: undefined,
items: items.map((item) => ({ productId: item.productId, quantity: item.quantity })),
discountCode: String(formData.get("discountCode") ?? "").trim() || undefined,
giftMessage: isGift ? String(formData.get("giftMessage") ?? "").trim() || undefined : undefined,
@@ -196,7 +272,29 @@ export function CheckoutForm({
) : null}
</div>
{fulfillment === "VERSAND" ? (
{fulfillment === "VERSAND" && packstationSearchAvailable ? (
<div className="flex gap-3">
{(["ADRESSE", "PACKSTATION"] as const).map((option) => (
<button
key={option}
type="button"
onClick={() => setDeliveryTarget(option)}
aria-pressed={deliveryTarget === option}
className={`flex-1 border px-4 py-3 text-sm transition-colors ${
deliveryTarget === option
? "border-forest bg-forest text-cream"
: "border-ink/20 hover:border-forest"
}`}
>
{option === "ADRESSE"
? t.checkoutPage.deliveryTargetAddress
: t.checkoutPage.deliveryTargetPackstation}
</button>
))}
</div>
) : null}
{fulfillment === "VERSAND" && !usesPackstation ? (
<div className="space-y-4">
<Field
name="street"
@@ -225,6 +323,102 @@ export function CheckoutForm({
</div>
) : null}
{usesPackstation ? (
<div className="space-y-4 border border-ink/15 p-4">
<p className="text-xs text-ink/70">{t.checkoutPage.packstationIntro}</p>
{selectedPackstation ? (
<div className="flex items-start justify-between gap-4 border border-forest/40 bg-forest/5 p-3">
<div className="text-sm">
<p className="text-xs tracking-wide-caps text-ink/70">
{t.checkoutPage.packstationSelectedLabel}
</p>
<p className="mt-1 text-forest">{selectedPackstation.name}</p>
<p className="text-ink/70">
{selectedPackstation.street ? `${selectedPackstation.street}, ` : ""}
{selectedPackstation.zip} {selectedPackstation.city}
</p>
</div>
<button
type="button"
onClick={() => setSelectedPackstation(null)}
className="shrink-0 text-xs text-ink/70 underline"
>
{t.checkoutPage.packstationChange}
</button>
</div>
) : (
<>
<div className="flex items-end gap-3">
<label className="block flex-1 text-sm">
<span className="text-ink/70">{t.checkoutPage.packstationZipLabel}</span>
<input
value={packstationZip}
onChange={(event) => setPackstationZip(event.target.value)}
inputMode="numeric"
maxLength={5}
autoComplete="postal-code"
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
<button
type="button"
onClick={searchPackstations}
disabled={packstationSearching || packstationZip.trim().length !== 5}
className="border border-ink/20 bg-background px-4 py-2 text-sm hover:border-forest disabled:opacity-50"
>
{packstationSearching
? t.checkoutPage.packstationSearching
: t.checkoutPage.packstationSearchButton}
</button>
</div>
{packstationError ? (
<p className="text-xs text-red-700">{packstationError}</p>
) : null}
{packstationResults?.length === 0 ? (
<p className="text-xs text-ink/70">{t.checkoutPage.packstationNoResults}</p>
) : null}
{packstationResults && packstationResults.length > 0 ? (
<ul className="space-y-2">
{packstationResults.map((packstation) => (
<li key={packstation.number}>
<button
type="button"
onClick={() => setSelectedPackstation(packstation)}
className="w-full border border-ink/20 px-3 py-2 text-left text-sm hover:border-forest"
>
<span className="text-forest">{packstation.name}</span>
<span className="block text-xs text-ink/70">
{packstation.street ? `${packstation.street}, ` : ""}
{packstation.zip} {packstation.city}
{packstation.distanceMeters !== null
? ` — ${t.checkoutPage.packstationDistance(
Math.round(packstation.distanceMeters)
)}`
: ""}
</span>
</button>
</li>
))}
</ul>
) : null}
</>
)}
<Field
name="postNumber"
label={t.checkoutPage.postNumberLabel}
inputMode="numeric"
autoComplete="off"
required
/>
<p className="text-xs text-ink/70">{t.checkoutPage.postNumberHint}</p>
</div>
) : null}
<Field
name="discountCode"
label={t.checkoutPage.discountCodeLabel}
+12 -2
View File
@@ -6,6 +6,7 @@ import { getBulkDiscountTiers } from "@/lib/bulk-discount";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { CheckoutForm, type CheckoutInitialValues } from "./checkout-form";
import { isPackstationSearchConfigured } from "@/lib/dhl-locations";
// Vorausfuellen aus Kundendaten aendert sich pro Nutzer/Anmeldestatus, und
// Versandstaffeln koennen sich im Admin-Bereich jederzeit aendern —
@@ -27,7 +28,11 @@ export default async function CheckoutPage() {
}));
const customer = await getCurrentCustomer();
if (!customer) return <CheckoutForm tiers={tierValues} bulkDiscountTiers={bulkDiscountTiers} />;
if (!customer) return <CheckoutForm
tiers={tierValues}
bulkDiscountTiers={bulkDiscountTiers}
packstationSearchAvailable={isPackstationSearchConfigured()}
/>;
// getAddressesForCustomer sortiert Standardadresse (isDefault) zuerst, sonst
// die zuletzt angelegte — die erste Adresse in der Liste ist also immer die
@@ -45,5 +50,10 @@ export default async function CheckoutPage() {
city: defaultAddress?.city ?? "",
};
return <CheckoutForm initialValues={initialValues} tiers={tierValues} bulkDiscountTiers={bulkDiscountTiers} />;
return <CheckoutForm
initialValues={initialValues}
tiers={tierValues}
bulkDiscountTiers={bulkDiscountTiers}
packstationSearchAvailable={isPackstationSearchConfigured()}
/>;
}