From 9dd71ffb9a7c7e14eaae85210f605291077e870f Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Wed, 16 Sep 2026 00:37:00 +0200 Subject: [PATCH] feat: Packstation ueber DHL-Standortsuche als Lieferziel waehlbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 9 + .../migration.sql | 7 + prisma/schema.prisma | 24 ++ src/app/admin/actions/dhl.ts | 4 + src/app/admin/bestellungen/[id]/page.tsx | 8 + src/app/api/checkout/route.ts | 7 + src/app/api/shop/packstationen/route.ts | 76 +++++++ src/app/checkout/checkout-form.tsx | 202 ++++++++++++++++- src/app/checkout/page.tsx | 14 +- src/i18n/dictionaries/de.ts | 17 ++ src/i18n/dictionaries/en.ts | 17 ++ src/i18n/dictionary-type.ts | 14 ++ src/lib/dhl-locations.test.ts | 89 ++++++++ src/lib/dhl-locations.ts | 212 ++++++++++++++++++ src/lib/dhl.ts | 38 +++- src/lib/validation.ts | 27 ++- 16 files changed, 757 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/20260915222938_add_packstation_delivery/migration.sql create mode 100644 src/app/api/shop/packstationen/route.ts create mode 100644 src/lib/dhl-locations.test.ts create mode 100644 src/lib/dhl-locations.ts diff --git a/.env.example b/.env.example index aaf6403..79286be 100644 --- a/.env.example +++ b/.env.example @@ -123,6 +123,15 @@ APICORE_SYNC_PASSWORD= # im Zweifel im DHL-Developer-Portal unter der App nachschauen): # DHL_GKP_USERNAME=user-valid # DHL_GKP_PASSWORD=SandboxPasswort2023! +# HINWEIS: Fuer die PACKSTATION-SUCHE im Checkout reicht DHL_API_KEY allein. +# Sie laeuft ueber die DHL Location Finder API (lib/dhl-locations.ts), einen +# eigenen Dienst mit eigener Authentifizierung — nur der API-Key im Header, +# kein OAuth und kein Geschaeftskundenvertrag. Ist der Key gesetzt, bietet der +# Checkout "An eine Packstation" als Lieferziel an; fehlt er, erscheint die +# Option gar nicht erst. Die uebrigen DHL_*-Werte braucht erst das Erstellen +# von Versandmarken im Admin-Bereich. Kontingent laut DHL: 500 Aufrufe/Tag — +# der Endpunkt /api/shop/packstationen ist deshalb streng rate-limitiert und +# cached Ergebnisse 15 Minuten. DHL_API_KEY= DHL_API_SECRET= DHL_GKP_USERNAME= diff --git a/prisma/migrations/20260915222938_add_packstation_delivery/migration.sql b/prisma/migrations/20260915222938_add_packstation_delivery/migration.sql new file mode 100644 index 0000000..906aa43 --- /dev/null +++ b/prisma/migrations/20260915222938_add_packstation_delivery/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "addresses" ADD COLUMN "packstationNumber" TEXT, +ADD COLUMN "postNumber" TEXT; + +-- AlterTable +ALTER TABLE "orders" ADD COLUMN "packstationNumber" TEXT, +ADD COLUMN "postNumber" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 78a6ef5..c4b8def 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -118,6 +118,18 @@ model Address { zip String city String country String @default("DE") + + /// Lieferung an eine DHL-Packstation statt an eine Hausanschrift. Enthaelt + /// die Packstation-Nummer, wie sie auf dem Geraet steht (z.B. "145") — + /// gefunden ueber die DHL-Standortsuche (lib/dhl-locations.ts). + /// Ist das Feld gesetzt, tragen street/zip/city zusaetzlich die lesbare + /// Anschrift der Packstation, damit Rechnung, E-Mails und Admin-Ansicht + /// unveraendert etwas Sinnvolles anzeigen. + packstationNumber String? + /// DHL-Postnummer der empfangenden Person (6-10 Ziffern). Bei Lieferung an + /// eine Packstation von DHL zwingend verlangt — ohne sie laesst sich keine + /// Versandmarke erstellen (siehe lib/dhl.ts). + postNumber String? isDefault Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -340,6 +352,18 @@ model Order { city String country String @default("DE") + /// Lieferung an eine DHL-Packstation statt an eine Hausanschrift. Enthaelt + /// die Packstation-Nummer, wie sie auf dem Geraet steht (z.B. "145") — + /// gefunden ueber die DHL-Standortsuche (lib/dhl-locations.ts). + /// Ist das Feld gesetzt, tragen street/zip/city zusaetzlich die lesbare + /// Anschrift der Packstation, damit Rechnung, E-Mails und Admin-Ansicht + /// unveraendert etwas Sinnvolles anzeigen. + packstationNumber String? + /// DHL-Postnummer der empfangenden Person (6-10 Ziffern). Bei Lieferung an + /// eine Packstation von DHL zwingend verlangt — ohne sie laesst sich keine + /// Versandmarke erstellen (siehe lib/dhl.ts). + postNumber String? + subtotalCents Int shippingCents Int @default(0) discountCents Int @default(0) diff --git a/src/app/admin/actions/dhl.ts b/src/app/admin/actions/dhl.ts index 4fa0ae8..7e85579 100644 --- a/src/app/admin/actions/dhl.ts +++ b/src/app/admin/actions/dhl.ts @@ -107,6 +107,10 @@ export async function createDhlLabelAction(formData: FormData): Promise { 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, diff --git a/src/app/admin/bestellungen/[id]/page.tsx b/src/app/admin/bestellungen/[id]/page.tsx index f8ae4c0..3338e1a 100644 --- a/src/app/admin/bestellungen/[id]/page.tsx +++ b/src/app/admin/bestellungen/[id]/page.tsx @@ -93,6 +93,14 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best <>

{order.street}

{order.zip} {order.city}

+ {/* 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 ? ( +

+ {t.adminOrderDetailPage.postNumberLabel}: {order.postNumber ?? "—"} +

+ ) : null}

{order.shipment?.shippedAt ? t.adminOrderDetailPage.shippedOn(formatDate(order.shipment.shippedAt)) diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts index 46e7e4a..450cca7 100644 --- a/src/app/api/checkout/route.ts +++ b/src/app/api/checkout/route.ts @@ -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, diff --git a/src/app/api/shop/packstationen/route.ts b/src/app/api/shop/packstationen/route.ts new file mode 100644 index 0000000..046a1a4 --- /dev/null +++ b/src/app/api/shop/packstationen/route.ts @@ -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; + } +} diff --git a/src/app/checkout/checkout-form.tsx b/src/app/checkout/checkout-form.tsx index 736bc29..7e8814a 100644 --- a/src/app/checkout/checkout-form.tsx +++ b/src/app/checkout/checkout-form.tsx @@ -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(null); const [isGift, setIsGift] = useState(false); + + const [deliveryTarget, setDeliveryTarget] = useState("ADRESSE"); + const [packstationZip, setPackstationZip] = useState(""); + const [packstationResults, setPackstationResults] = useState(null); + const [packstationSearching, setPackstationSearching] = useState(false); + const [packstationError, setPackstationError] = useState(null); + const [selectedPackstation, setSelectedPackstation] = useState(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(null); // Fokus aktiv auf die Fehlermeldung verschieben statt sie nur farblich @@ -67,6 +118,14 @@ export function CheckoutForm({ async function handleSubmit(event: FormEvent) { 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} - {fulfillment === "VERSAND" ? ( + {fulfillment === "VERSAND" && packstationSearchAvailable ? ( +

+ {(["ADRESSE", "PACKSTATION"] as const).map((option) => ( + + ))} +
+ ) : null} + + {fulfillment === "VERSAND" && !usesPackstation ? (
) : null} + {usesPackstation ? ( +
+

{t.checkoutPage.packstationIntro}

+ + {selectedPackstation ? ( +
+
+

+ {t.checkoutPage.packstationSelectedLabel} +

+

{selectedPackstation.name}

+

+ {selectedPackstation.street ? `${selectedPackstation.street}, ` : ""} + {selectedPackstation.zip} {selectedPackstation.city} +

+
+ +
+ ) : ( + <> +
+ + +
+ + {packstationError ? ( +

{packstationError}

+ ) : null} + + {packstationResults?.length === 0 ? ( +

{t.checkoutPage.packstationNoResults}

+ ) : null} + + {packstationResults && packstationResults.length > 0 ? ( +
    + {packstationResults.map((packstation) => ( +
  • + +
  • + ))} +
+ ) : null} + + )} + + +

{t.checkoutPage.postNumberHint}

+
+ ) : null} + ; + if (!customer) return ; // 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 ; + return ; } diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index c34874e..f399a88 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -1034,6 +1034,7 @@ export const de: Dictionary = { noInquiriesYet: "Noch keine Firmenanfragen eingegangen.", }, adminOrderDetailPage: { + postNumberLabel: "DHL-Postnummer", backToOrders: "Zurück zu Bestellungen", saveStatus: "Status speichern", giftHeading: "🎁 Geschenk: Nachricht beilegen", @@ -1324,6 +1325,22 @@ export const de: Dictionary = { shippingOption: "Versand", pickupOption: "Abholung in Altenmünster", deliveryTimeNote: "Übliche Lieferzeit: 2–4 Werktage nach Zahlungseingang.", + deliveryTargetAddress: "An meine Adresse", + deliveryTargetPackstation: "An eine Packstation", + packstationIntro: + "Gib die Postleitzahl ein, in deren Nähe die Packstation liegen soll. Die Standorte kommen direkt von DHL.", + packstationZipLabel: "Postleitzahl", + packstationSearchButton: "Packstationen suchen", + packstationSearching: "Suche läuft …", + packstationNoResults: "Zu dieser Postleitzahl wurde keine Packstation gefunden.", + packstationChooseHint: "Bitte wähle eine Packstation aus der Liste.", + packstationSelectedLabel: "Gewählte Packstation", + packstationChange: "Andere Packstation wählen", + packstationDistance: (meters: number) => + meters < 1000 ? `ca. ${meters} m entfernt` : `ca. ${(meters / 1000).toFixed(1)} km entfernt`, + postNumberLabel: "DHL-Postnummer", + postNumberHint: + "Deine 6- bis 10-stellige Postnummer aus dem DHL-Kundenkonto. DHL kann ohne sie nicht an eine Packstation liefern.", streetLabel: "Straße und Hausnummer", zipLabel: "PLZ", cityLabel: "Ort", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 3873fc8..f3b307b 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -1034,6 +1034,7 @@ export const en: Dictionary = { noInquiriesYet: "No business inquiries yet.", }, adminOrderDetailPage: { + postNumberLabel: "DHL Post Number", backToOrders: "Back to orders", saveStatus: "Save status", giftHeading: "🎁 Gift: enclose message", @@ -1324,6 +1325,22 @@ export const en: Dictionary = { shippingOption: "Shipping", pickupOption: "Pickup in Altenmünster", deliveryTimeNote: "Typical delivery time: 2–4 business days after payment.", + deliveryTargetAddress: "To my address", + deliveryTargetPackstation: "To a DHL Packstation", + packstationIntro: + "Enter the postal code the Packstation should be near. Locations come directly from DHL.", + packstationZipLabel: "Postal code", + packstationSearchButton: "Search Packstations", + packstationSearching: "Searching …", + packstationNoResults: "No Packstation was found for this postal code.", + packstationChooseHint: "Please pick a Packstation from the list.", + packstationSelectedLabel: "Selected Packstation", + packstationChange: "Choose a different Packstation", + packstationDistance: (meters: number) => + meters < 1000 ? `approx. ${meters} m away` : `approx. ${(meters / 1000).toFixed(1)} km away`, + postNumberLabel: "DHL Post Number", + postNumberHint: + "Your 6 to 10 digit DHL Post Number. DHL cannot deliver to a Packstation without it.", streetLabel: "Street and house number", zipLabel: "Postal code", cityLabel: "City", diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index 9947a9f..c8ddc62 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -734,6 +734,7 @@ export interface Dictionary { noInquiriesYet: string; }; adminOrderDetailPage: { + postNumberLabel: string; backToOrders: string; saveStatus: string; giftHeading: string; @@ -1011,6 +1012,19 @@ export interface Dictionary { shippingOption: string; pickupOption: string; deliveryTimeNote: string; + deliveryTargetAddress: string; + deliveryTargetPackstation: string; + packstationIntro: string; + packstationZipLabel: string; + packstationSearchButton: string; + packstationSearching: string; + packstationNoResults: string; + packstationChooseHint: string; + packstationSelectedLabel: string; + packstationChange: string; + packstationDistance: (meters: number) => string; + postNumberLabel: string; + postNumberHint: string; streetLabel: string; zipLabel: string; cityLabel: string; diff --git a/src/lib/dhl-locations.test.ts b/src/lib/dhl-locations.test.ts new file mode 100644 index 0000000..2402f82 --- /dev/null +++ b/src/lib/dhl-locations.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { parseLocations } from "./dhl-locations"; + +/** + * Ein Treffer im Format der DHL Location Finder API. Die Packstation-Nummer + * steht in location.keywordId — location.ids[0].locationId ist ein + * zusammengesetzter Systemcode und ausdruecklich NICHT die Nummer, die + * Kundschaft am Geraet eingibt. + */ +function dhlLocation(overrides: Record = {}) { + return { + url: "https://api.dhl.com/location-finder/v1/locations/8003-453113145", + location: { + ids: [{ locationId: "8003-453113145", provider: "parcel-de" }], + keyword: "DHL Packstation", + keywordId: "145", + type: "locker", + }, + name: "Packstation 145", + distance: 820, + place: { + address: { + countryCode: "DE", + postalCode: "86450", + addressLocality: "Altenmünster", + streetAddress: "Bahnhofstr. 1", + }, + }, + ...overrides, + }; +} + +describe("parseLocations", () => { + it("nimmt die Packstation-Nummer aus keywordId, nicht aus locationId", () => { + const [packstation] = parseLocations([dhlLocation()]); + + // locationId waere "8003-453113145" — damit koennte niemand etwas anfangen, + // und DHL wuerde die Versandmarke ablehnen. + expect(packstation.number).toBe("145"); + expect(packstation.zip).toBe("86450"); + expect(packstation.city).toBe("Altenmünster"); + expect(packstation.street).toBe("Bahnhofstr. 1"); + expect(packstation.distanceMeters).toBe(820); + }); + + it("sortiert nach Entfernung aufsteigend", () => { + const parsed = parseLocations([ + dhlLocation({ distance: 3000, location: { keywordId: "300", keyword: "DHL Packstation" } }), + dhlLocation({ distance: 120, location: { keywordId: "120", keyword: "DHL Packstation" } }), + ]); + + expect(parsed.map((p) => p.number)).toEqual(["120", "300"]); + }); + + it("ueberspringt einen Eintrag ohne Packstation-Nummer, statt die ganze Suche scheitern zu lassen", () => { + const parsed = parseLocations([ + { location: { keyword: "DHL Packstation" }, place: { address: {} } }, + dhlLocation(), + ]); + + // Ein Standort ohne Nummer ist unbrauchbar — die uebrigen bleiben gueltig. + expect(parsed).toHaveLength(1); + expect(parsed[0].number).toBe("145"); + }); + + it("faellt auf einen zusammengesetzten Namen zurueck, wenn DHL keinen liefert", () => { + const [packstation] = parseLocations([dhlLocation({ name: undefined })]); + + expect(packstation.name).toBe("Packstation 145"); + }); + + it("kommt ohne Strasse und ohne Entfernung zurecht", () => { + const [packstation] = parseLocations([ + dhlLocation({ + distance: undefined, + place: { address: { postalCode: "10115", addressLocality: "Berlin" } }, + }), + ]); + + expect(packstation.street).toBe(""); + expect(packstation.distanceMeters).toBeNull(); + // Ohne Entfernung darf der Eintrag nicht verschwinden, nur ans Ende rutschen. + expect(packstation.city).toBe("Berlin"); + }); + + it("liefert fuer eine leere Trefferliste ein leeres Ergebnis", () => { + expect(parseLocations([])).toEqual([]); + }); +}); diff --git a/src/lib/dhl-locations.ts b/src/lib/dhl-locations.ts new file mode 100644 index 0000000..7b2953e --- /dev/null +++ b/src/lib/dhl-locations.ts @@ -0,0 +1,212 @@ +/** + * Client fuer die DHL Location Finder Unified API — sucht Packstationen in + * der Naehe einer Postleitzahl, damit sich beim Checkout eine Packstation + * statt einer Hausanschrift als Lieferziel waehlen laesst. + * + * Bewusst getrennt von lib/dhl.ts: das ist ein ANDERER DHL-Dienst mit + * anderer Authentifizierung. Die Standortsuche braucht nur den App-API-Key + * (Header "DHL-API-Key"), nicht den OAuth2/ROPC-Weg und keinen + * Geschaeftskundenvertrag. Die Packstation-Auswahl funktioniert damit auch, + * solange die Versandmarken-Erstellung noch nicht eingerichtet ist. + * + * Endpunkt laut oeffentlicher API-Referenz (developer.dhl.com/api-reference/ + * location-finder, Stand September 2026): + * GET https://api.dhl.com/location-finder/v1/find-by-address + * ?countryCode=DE&postalCode=...&locationType=locker&providerType=parcel + * Die Packstation-Nummer steht in location.keywordId; location.ids[0].locationId + * ist ein zusammengesetzter Systemcode und NICHT die Nummer, die Kundschaft + * auf dem Geraet sieht. + */ +import { z } from "zod"; + +/** Wird geworfen, wenn DHL_API_KEY fehlt — die Suche ist dann nicht nutzbar. */ +export class PackstationNotConfiguredError extends Error { + constructor() { + super( + "Die Packstation-Suche ist noch nicht konfiguriert. Bitte DHL_API_KEY in der .env-Datei setzen (siehe .env.example)." + ); + this.name = "PackstationNotConfiguredError"; + } +} + +/** Wird bei einem fehlgeschlagenen Aufruf der DHL-Standortsuche geworfen. */ +export class PackstationApiError extends Error { + constructor(message: string) { + super(message); + this.name = "PackstationApiError"; + } +} + +const LOCATION_FINDER_BASE_URL = "https://api.dhl.com/location-finder/v1"; + +// Ohne Timeout blockiert eine haengende (nicht fehlerhafte, sondern nie +// antwortende) DHL-API den Request-Handler unbegrenzt — gleiche Begruendung +// wie bei DHL_REQUEST_TIMEOUT_MS in lib/dhl.ts, hier kuerzer, weil die Suche +// waehrend des Checkouts interaktiv passiert. +const REQUEST_TIMEOUT_MS = 8_000; + +/** + * DHL nennt als Einstiegskontingent 500 Aufrufe pro TAG. Ein Suchfeld im + * Checkout kann das schnell aufbrauchen, deshalb ein kurzer Cache pro + * Suchbegriff: Packstation-Standorte aendern sich nicht im Minutentakt, und + * mehrere Kund:innen aus derselben Stadt suchen dieselbe Postleitzahl. + */ +const CACHE_TTL_MS = 15 * 60 * 1000; +const MAX_CACHE_ENTRIES = 500; +const cache = new Map(); + +export interface Packstation { + /** Packstation-Nummer, wie sie auf dem Geraet steht (z.B. "145"). */ + number: string; + /** Anzeigename laut DHL, z.B. "Packstation 145". */ + name: string; + street: string; + zip: string; + city: string; + /** Luftlinie in Metern zum Suchmittelpunkt, sofern DHL sie mitliefert. */ + distanceMeters: number | null; +} + +// Nur die Felder, die tatsaechlich gebraucht werden — alles Weitere bleibt +// bewusst ungeparst, damit ein zusaetzliches Feld aufseiten DHLs die Suche +// nicht bricht. Umgekehrt faellt eine fehlende Packstation-Nummer hier +// sichtbar durch, statt als leerer String in einer Bestellung zu landen. +const locationSchema = z.object({ + location: z.object({ + keywordId: z.string().min(1), + keyword: z.string().optional(), + }), + name: z.string().optional(), + distance: z.number().optional(), + place: z.object({ + address: z.object({ + postalCode: z.string(), + addressLocality: z.string(), + streetAddress: z.string().optional(), + }), + }), +}); + +const responseSchema = z.object({ + locations: z.array(z.unknown()).optional(), +}); + +export function isPackstationSearchConfigured(): boolean { + return Boolean(process.env.DHL_API_KEY?.trim()); +} + +interface FindPackstationsOptions { + /** Deutsche Postleitzahl, fuenfstellig. */ + postalCode: string; + /** Optionaler Ort — schraenkt bei mehrdeutigen Postleitzahlen weiter ein. */ + city?: string; + /** Obergrenze der Treffer (DHL-seitig zusaetzlich begrenzt). */ + limit?: number; +} + +/** + * Sucht Packstationen zu einer Postleitzahl. Liefert eine nach Entfernung + * aufsteigend sortierte Liste; DHL sortiert bereits so, die Sortierung hier + * ist nur die Absicherung dagegen, dass sich das stillschweigend aendert. + */ +export async function findPackstations({ + postalCode, + city, + limit = 10, +}: FindPackstationsOptions): Promise { + const apiKey = process.env.DHL_API_KEY?.trim(); + if (!apiKey) throw new PackstationNotConfiguredError(); + + const cacheKey = `${postalCode}|${city ?? ""}|${limit}`; + const cached = cache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) return cached.value; + + const query = new URLSearchParams({ + countryCode: "DE", + postalCode, + // "locker" = Packstation; "parcel" grenzt auf Post & Parcel Germany ein, + // damit keine DHL-Express-Standorte in der Liste landen. + locationType: "locker", + providerType: "parcel", + limit: String(Math.min(Math.max(limit, 1), 50)), + }); + if (city) query.set("addressLocality", city); + + let response: Response; + try { + response = await fetch(`${LOCATION_FINDER_BASE_URL}/find-by-address?${query}`, { + headers: { "DHL-API-Key": apiKey, Accept: "application/json" }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (error: unknown) { + throw new PackstationApiError( + error instanceof Error && error.name === "TimeoutError" + ? "Die DHL-Standortsuche hat nicht rechtzeitig geantwortet." + : "Die DHL-Standortsuche ist nicht erreichbar." + ); + } + + // 404 bedeutet bei diesem Endpunkt "keine Standorte gefunden", nicht + // "Endpunkt existiert nicht" — das ist ein gueltiges, leeres Ergebnis und + // darf nicht als Fehler in der Oberflaeche landen. + if (response.status === 404) { + rememberInCache(cacheKey, []); + return []; + } + + if (!response.ok) { + throw new PackstationApiError( + `Die DHL-Standortsuche hat mit Status ${response.status} geantwortet.` + ); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new PackstationApiError("Die DHL-Standortsuche hat keine gueltige Antwort geliefert."); + } + + const parsed = responseSchema.safeParse(payload); + if (!parsed.success) { + throw new PackstationApiError("Die Antwort der DHL-Standortsuche hat ein unerwartetes Format."); + } + + const packstations = parseLocations(parsed.data.locations ?? []); + rememberInCache(cacheKey, packstations); + return packstations; +} + +/** + * Als eigene Funktion exportiert, damit sich das Parsen der DHL-Antwort ohne + * Netzwerkzugriff testen laesst (siehe dhl-locations.test.ts). + */ +export function parseLocations(entries: unknown[]): Packstation[] { + return entries + .map((entry) => locationSchema.safeParse(entry)) + // Einzelne unvollstaendige Eintraege ueberspringen, statt die ganze Suche + // scheitern zu lassen: ein Standort ohne Nummer ist fuer uns unbrauchbar, + // die uebrigen bleiben aber gueltig. + .filter((result) => result.success) + .map(({ data }): Packstation => ({ + number: data.location.keywordId, + name: data.name?.trim() || `Packstation ${data.location.keywordId}`, + street: data.place.address.streetAddress?.trim() ?? "", + zip: data.place.address.postalCode, + city: data.place.address.addressLocality, + distanceMeters: typeof data.distance === "number" ? data.distance : null, + })) + .sort((a, b) => (a.distanceMeters ?? Infinity) - (b.distanceMeters ?? Infinity)); +} + +function rememberInCache(key: string, value: Packstation[]): void { + // Speicher begrenzen: abgelaufene Eintraege gelegentlich aufraeumen + // (gleiches Muster wie in lib/rate-limit.ts). + if (cache.size > MAX_CACHE_ENTRIES) { + const now = Date.now(); + for (const [existingKey, entry] of cache) { + if (entry.expiresAt <= now) cache.delete(existingKey); + } + } + cache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, value }); +} diff --git a/src/lib/dhl.ts b/src/lib/dhl.ts index 26b0b4d..de9af7e 100644 --- a/src/lib/dhl.ts +++ b/src/lib/dhl.ts @@ -223,6 +223,14 @@ export interface DhlAddressInput { country: string; phone?: string | null; email?: string | null; + /** + * Nur bei Lieferung an eine DHL-Packstation gesetzt (Order.packstationNumber). + * Ist sie vorhanden, verlangt DHL eine voellig andere Empfaengerstruktur — + * siehe buildDhlConsignee() unten. + */ + packstationNumber?: string | null; + /** DHL-Postnummer der empfangenden Person, 6-10 Ziffern (Order.postNumber). */ + postNumber?: string | null; } export interface DhlShipperInput extends DhlAddressInput { @@ -265,6 +273,31 @@ function buildDhlContact(address: DhlAddressInput, name1: string) { }; } +/** + * Empfaengerblock fuer die Shipping-API v2. + * + * DHL kennt fuer den Empfaenger zwei sich gegenseitig ausschliessende + * Formen: eine normale Hausanschrift mit Strasse und Hausnummer, oder eine + * Packstation. Letztere wird NICHT als Strasse uebermittelt — DHL erwartet + * stattdessen "lockerID" (die Packstation-Nummer) zusammen mit der + * "postNumber" der empfangenden Person; Strassen- und Hausnummernfelder + * entfallen dabei ersatzlos. Eine Packstation als Strassentext zu schicken + * ergaebe eine Marke auf eine Anschrift, die es so nicht gibt. + */ +function buildDhlConsignee(address: DhlAddressInput, name: string) { + if (address.packstationNumber && address.postNumber) { + return { + name, + lockerID: address.packstationNumber, + postNumber: address.postNumber, + postalCode: address.zip, + city: address.city, + country: toIso3Country(address.country), + }; + } + return buildDhlContact(address, name); +} + export async function createDhlShipmentLabel( input: DhlCreateShipmentInput ): Promise { @@ -279,7 +312,10 @@ export async function createDhlShipmentLabel( // DHL erlaubt max. 35 Zeichen fuer refNo. refNo: input.reference.slice(0, 35), shipper: buildDhlContact(input.shipper, input.shipper.companyName), - consignee: buildDhlContact(input.receiver, `${input.receiver.firstName} ${input.receiver.lastName}`.trim()), + consignee: buildDhlConsignee( + input.receiver, + `${input.receiver.firstName} ${input.receiver.lastName}`.trim() + ), details: { weight: { uom: "g", value: Math.max(Math.round(input.weightGrams), 1) }, }, diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 80d0c80..2f6b3d6 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -157,7 +157,32 @@ export const checkoutSchema = z.object({ .max(50, "Zu viele verschiedene Positionen im Warenkorb."), discountCode: z.string().trim().max(50).optional(), giftMessage: z.string().trim().max(500, "Die Geschenknachricht darf höchstens 500 Zeichen lang sein.").optional(), -}); + /** Nummer der ueber die DHL-Standortsuche gewaehlten Packstation (z.B. "145"). */ + packstationNumber: z + .string() + .trim() + .regex(/^d{1,10}$/, "Die Packstation-Nummer besteht nur aus Ziffern.") + .optional(), + /** DHL-Postnummer der empfangenden Person — bei Packstation-Lieferung Pflicht. */ + postNumber: z + .string() + .trim() + .regex(/^d{6,10}$/, "Die DHL-Postnummer besteht aus 6 bis 10 Ziffern.") + .optional(), +}) + // DHL verlangt bei Lieferung an eine Packstation zwingend die Postnummer + // der empfangenden Person — ohne sie laesst sich spaeter keine + // Versandmarke erstellen, die Bestellung waere also nicht zustellbar. + .refine((data) => !data.packstationNumber || Boolean(data.postNumber), { + message: "Für die Lieferung an eine Packstation wird deine DHL-Postnummer benötigt.", + path: ["postNumber"], + }) + // Eine Packstation ist ein Versandziel — bei Abholung ergibt sie keinen Sinn + // und wuerde sonst still mitgespeichert. + .refine((data) => !data.packstationNumber || data.fulfillmentMethod === "VERSAND", { + message: "Eine Packstation ist nur bei Versand möglich.", + path: ["packstationNumber"], + }); export type CheckoutInput = z.infer; export const reviewSchema = z.object({