feat: Packstation ueber DHL-Standortsuche als Lieferziel waehlbar
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m8s
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:
1 parent
ff365c3cb1
commit
9dd71ffb9a
16 files changed
+757
-8
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user