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
+756
-7
No files matched your search
@@ -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=
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
/>;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, { expiresAt: number; value: Packstation[] }>();
|
||||
|
||||
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<Packstation[]> {
|
||||
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 });
|
||||
}
|
||||
+37
-1
@@ -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<DhlCreateShipmentResult> {
|
||||
@@ -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) },
|
||||
},
|
||||
|
||||
@@ -157,6 +157,31 @@ 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<typeof checkoutSchema>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user