Compare commits

..
2 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5 9dd71ffb9a 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]>
2026-09-16 00:37:00 +02:00
Nick SzaunigandClaude Opus 5 ff365c3cb1 feat: Chargen- und Erntebilder einzeln pro Kachel pflegbar
Die Chargenseite (/charge/[code]) zeigt zwei Bildkacheln: links Ernte und
Jahr, rechts den Standort. Gefuellt wurden sie aus einer Bilder-LISTE, in
der allein die Reihenfolge entschied, welches Bild wohin kommt.

Damit liess sich das zweite Bild nicht allein setzen. Wer nur ein Foto vom
Standort pflegen wollte, bekam es auf Position 0 — also in die linke
Kachel, wo Ernte und Jahr stehen sollten. Welche Kachel ein Bild fuellt,
war im Formular ausserdem nirgends erkennbar.

Jetzt zwei benannte Slots statt einer Liste, im Chargen- wie im
Ernten-Formular:

  Bild 1 — linke Kachel auf der Chargenseite
  Bild 2 — rechte Kachel auf der Chargenseite (Standort)

Jeder Slot hat eine eigene Vorschau, einen eigenen Auswahl- und einen
eigenen Entfernen-Knopf. Beide tragen denselben Feldnamen "images" und
werden in dieser Reihenfolge uebermittelt.

Dafuer brauchte es einen positionserhaltenden Parser: strArray() trimmt
und filtert leere Werte heraus, wodurch ein leerer erster Slot
verschwunden und der zweite nach vorn gerutscht waere — genau der Fehler,
der behoben werden sollte. Neu ist strArraySlots(), das leere Slots als ""
stehen laesst und nur am Ende stehende entfernt (ein leeres Feld ergibt
weiterhin [] statt ["", ""]).

Die Konsumenten mussten entsprechend von "??" auf "||" wechseln: ein im
Admin geleerter Slot kommt als leerer String an, nicht als undefined. Mit
"??" haette dieser leere String als gueltiger Wert gegolten und das
Erntebild darunter verdeckt (charge/[code], ernten, ernten/[year]/[type]).

strArray/strArraySlots sind nach lib/form-fields.ts gewandert und werden
aus _shared.ts re-exportiert — saemtliche bestehenden Importe bleiben
unveraendert. Grund: _shared.ts zieht ueber lib/prisma.ts einen
Datenbank-Adapter nach und liess sich ohne DATABASE_URL nicht testen. Als
reine Funktionen ohne Datenbankbezug sind die beiden jetzt direkt
pruefbar, passend zur bereits dokumentierten Teststrategie des Projekts.

Sieben neue Tests decken den Kernfall ab (leerer erster Slot bleibt
offen), mit Gegenprobe, dass strArray denselben Fall weiterhin
zusammenrutschen laesst.

Produktbilder bleiben bewusst bei der bisherigen Liste: dort ist die
Bilderzahl offen und die Reihenfolge traegt keine Kachel-Bedeutung.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 21:10:12 +02:00
25 changed files with 935 additions and 31 deletions

No files matched your search

+9
View File
@@ -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;
+24
View File
@@ -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)
+6 -6
View File
@@ -59,9 +59,9 @@ export function strOrNull(formData: FormData, key: string): string | null {
* Inputs aus AdminImageListField, je Bild ein eigenes Feld mit demselben
* Namen) als getrimmtes String-Array ohne leere Eintraege.
*/
export function strArray(formData: FormData, key: string): string[] {
return formData
.getAll(key)
.map((value) => String(value).trim())
.filter(Boolean);
}
// Reine FormData-Leser ohne Datenbankbezug liegen in lib/form-fields.ts,
// damit sie sich ohne DATABASE_URL testen lassen (_shared.ts zieht ueber
// lib/prisma.ts einen Datenbank-Adapter nach, sobald es importiert wird).
// Re-Export, damit alle bestehenden Importe aus dieser Datei weiterhin
// funktionieren.
export { strArray, strArraySlots } from "@/lib/form-fields";
+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,
+20 -5
View File
@@ -5,7 +5,22 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { isValidUploadPath } from "@/lib/uploads";
import { requireAdmin, fail, str, num, strArray } from "./_shared";
import { requireAdmin, fail, str, num, strArraySlots } from "./_shared";
/**
* Bildslots fuer Ernte und Charge. Die Position entscheidet, WELCHE Kachel
* auf /charge/[code] das Bild fuellt (Slot 1 links, Slot 2 rechts/Standort) —
* deshalb ein leerer Slot als "" statt eines Eintrags weniger. Ein ungueltiger
* Pfad wird ebenfalls zu "" und damit zum Platzhalter, statt die
* nachfolgenden Slots zu verschieben.
*/
function imageSlots(formData: FormData): string[] {
const slots = strArraySlots(formData, "images").map((path) =>
isValidUploadPath(path) ? path : ""
);
while (slots.length > 0 && slots[slots.length - 1] === "") slots.pop();
return slots;
}
export async function createHarvestAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
@@ -26,7 +41,7 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
harvestDate: new Date(str(formData, "harvestDate")),
location: str(formData, "location") || "Altenmünster",
description: str(formData, "description"),
images: strArray(formData, "images").filter(isValidUploadPath),
images: imageSlots(formData),
isPublished,
},
});
@@ -56,7 +71,7 @@ export async function updateHarvestAction(formData: FormData): Promise<void> {
harvestDate: new Date(str(formData, "harvestDate")),
location: str(formData, "location") || "Altenmünster",
description: str(formData, "description"),
images: strArray(formData, "images").filter(isValidUploadPath),
images: imageSlots(formData),
isPublished,
},
});
@@ -147,7 +162,7 @@ export async function createBatchAction(formData: FormData): Promise<void> {
bestBeforeDate: new Date(str(formData, "bestBeforeDate")),
glassCount: num(formData, "glassCount"),
remainingGlasses: num(formData, "remainingGlasses"),
images: strArray(formData, "images").filter(isValidUploadPath),
images: imageSlots(formData),
},
});
@@ -168,7 +183,7 @@ export async function updateBatchAction(formData: FormData): Promise<void> {
bestBeforeDate: new Date(str(formData, "bestBeforeDate")),
glassCount: num(formData, "glassCount"),
remainingGlasses: num(formData, "remainingGlasses"),
images: strArray(formData, "images").filter(isValidUploadPath),
images: imageSlots(formData),
},
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { strArray, strArraySlots } from "@/lib/form-fields";
/**
* Chargen- und Erntebilder werden ueber ZWEI Formularfelder mit demselben
* Namen "images" uebermittelt — die Position entscheidet, welche Kachel auf
* /charge/[code] gefuellt wird (Slot 1 links, Slot 2 rechts/Standort).
* strArraySlots muss diese Position erhalten, strArray darf es weiterhin
* nicht (Produktbilder sind eine echte, lueckenlose Liste).
*/
function formWith(...images: string[]): FormData {
const formData = new FormData();
for (const image of images) formData.append("images", image);
return formData;
}
describe("strArraySlots", () => {
it("haelt einen leeren ersten Slot offen, damit das zweite Bild in der rechten Kachel landet", () => {
const slots = strArraySlots(formWith("", "/uploads/standort.webp"), "images");
// Genau der Fall, der vorher unmoeglich war: nur das Standortbild pflegen.
expect(slots).toEqual(["", "/uploads/standort.webp"]);
});
it("laesst strArray denselben Fall weiterhin zusammenrutschen", () => {
// Gegenprobe zur Begruendung der zweiten Funktion: mit strArray waere das
// Standortbild auf Position 0 gerutscht und in der linken Kachel gelandet.
expect(strArray(formWith("", "/uploads/standort.webp"), "images")).toEqual([
"/uploads/standort.webp",
]);
});
it("behaelt beide Slots, wenn beide gefuellt sind", () => {
expect(strArraySlots(formWith("/uploads/a.webp", "/uploads/b.webp"), "images")).toEqual([
"/uploads/a.webp",
"/uploads/b.webp",
]);
});
it("entfernt am Ende stehende leere Slots", () => {
// Nur das erste Bild gepflegt: der leere zweite Slot muss weg, sonst
// stuende in der Datenbank ["/uploads/a.webp", ""] statt eines Eintrags.
expect(strArraySlots(formWith("/uploads/a.webp", ""), "images")).toEqual([
"/uploads/a.webp",
]);
});
it("ergibt fuer ein vollstaendig leeres Feld ein leeres Array, nicht zwei leere Slots", () => {
expect(strArraySlots(formWith("", ""), "images")).toEqual([]);
});
it("schneidet Leerzeichen ab", () => {
expect(strArraySlots(formWith(" /uploads/a.webp ", " "), "images")).toEqual([
"/uploads/a.webp",
]);
});
it("liefert ein leeres Array, wenn das Feld gar nicht vorkommt", () => {
expect(strArraySlots(new FormData(), "images")).toEqual([]);
});
});
+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))
+20 -4
View File
@@ -2,7 +2,7 @@ import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array";
import { BackLink } from "@/components/back-link";
import { AdminImageListField } from "@/components/admin-image-list-field";
import { AdminImageField } from "@/components/admin-image-field";
import { updateBatchAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
@@ -67,11 +67,27 @@ export default async function AdminBatchEditPage(props: PageProps<"/admin/charge
/>
</label>
</div>
<AdminImageListField
{/* Zwei einzeln setzbare Slots statt einer Liste: die Position
entscheidet, welche Kachel auf /charge/[code] gefuellt wird.
Beide tragen denselben Feldnamen "images" und werden in dieser
Reihenfolge uebermittelt (siehe imageSlots() in
admin/actions/harvests-batches.ts). Ein leerer Slot faellt auf
das entsprechende Erntebild zurueck. */}
<fieldset className="border border-ink/10 p-4">
<legend className="px-1 text-sm text-ink/70">{t.adminBatchEditPage.imagesLabel}</legend>
<div className="space-y-4">
<AdminImageField
name="images"
label={t.adminBatchEditPage.imagesLabel}
initialValues={batch.images}
label={t.adminBatchEditPage.image1Label}
initialValue={batch.images[0] ?? ""}
/>
<AdminImageField
name="images"
label={t.adminBatchEditPage.image2Label}
initialValue={batch.images[1] ?? ""}
/>
</div>
</fieldset>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminBatchEditPage.save}
</button>
+18 -4
View File
@@ -2,7 +2,7 @@ import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array";
import { BackLink } from "@/components/back-link";
import { AdminImageListField } from "@/components/admin-image-list-field";
import { AdminImageField } from "@/components/admin-image-field";
import { updateHarvestAction, toggleHarvestPublishedAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
@@ -85,11 +85,25 @@ export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernt
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
<AdminImageListField
{/* Siehe Kommentar im Chargen-Formular: zwei benannte Slots statt
einer Liste, damit sich das zweite Bild auch ohne erstes setzen
laesst. Slot 1 erscheint zusaetzlich auf /ernten und der
Ernten-Detailseite. */}
<fieldset className="border border-ink/10 p-4">
<legend className="px-1 text-sm text-ink/70">{t.adminHarvestEditPage.imagesLabel}</legend>
<div className="space-y-4">
<AdminImageField
name="images"
label={t.adminHarvestEditPage.imagesLabel}
initialValues={harvest.images}
label={t.adminHarvestEditPage.image1Label}
initialValue={harvest.images[0] ?? ""}
/>
<AdminImageField
name="images"
label={t.adminHarvestEditPage.image2Label}
initialValue={harvest.images[1] ?? ""}
/>
</div>
</fieldset>
<label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isPublished" defaultChecked={harvest.isPublished} />
{t.adminHarvestEditPage.isPublishedLabel}
+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;
}
}
+6 -2
View File
@@ -44,9 +44,13 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
// das entsprechende Erntebild zurueck. Bewusst NICHT alles-oder-nichts:
// wer nur ein Chargenfoto pflegt, soll nicht das zweite (Standort-)Bild
// verlieren, nur weil die Charge kein zweites eigenes Foto hat.
// "||" statt "??": ein im Admin geleerter Slot kommt als leerer String
// an, nicht als undefined (siehe imageSlots() in
// admin/actions/harvests-batches.ts). Mit "??" wuerde dieser leere String
// als gueltiger Wert durchgehen und das Erntebild darunter verdecken.
const displayImages = [
batch.images[0] ?? batch.harvest.images[0] ?? null,
batch.images[1] ?? batch.harvest.images[1] ?? null,
batch.images[0] || batch.harvest.images[0] || null,
batch.images[1] || batch.harvest.images[1] || null,
];
return (
+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()}
/>;
}
+1 -1
View File
@@ -48,7 +48,7 @@ export default async function HarvestDetailPage(
<Container className="grid md:grid-cols-2 gap-14">
<ScrollReveal>
<ContentImage
src={harvest.images[0] ?? null}
src={harvest.images[0] || null}
alt={harvest.type}
tone="forest"
className="aspect-[4/5] w-full"
+1 -1
View File
@@ -48,7 +48,7 @@ export default async function ErntenPage() {
className="group block"
>
<ContentImage
src={harvest.images[0] ?? null}
src={harvest.images[0] || null}
alt={harvest.type}
tone="forest"
className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]"
+21
View File
@@ -842,6 +842,8 @@ export const de: Dictionary = {
glassCountLabel: "Anzahl Gläser",
remainingGlassesLabel: "Verbleibender Bestand",
imagesLabel: "Chargen-Bilder",
image1Label: "Bild 1 — linke Kachel auf der Chargenseite",
image2Label: "Bild 2 — rechte Kachel auf der Chargenseite (Standort)",
save: "Speichern",
},
trackOrderPage: {
@@ -921,6 +923,8 @@ export const de: Dictionary = {
locationLabel: "Standort",
descriptionLabel: "Beschreibung",
imagesLabel: "Erntebilder",
image1Label: "Bild 1 — Ernten-Übersicht und linke Kachel auf der Chargenseite",
image2Label: "Bild 2 — rechte Kachel auf der Chargenseite (Standort)",
isPublishedLabel: "Öffentlich sichtbar",
isPublishedHint: "Steuert, ob diese Ernte auf der Website (Shop, /ernten) sichtbar ist.",
draftNotice: "Diese Ernte ist aktuell nur im Admin-Bereich sichtbar (z.B. aus ApiCore importiert). Bitte Angaben prüfen und danach veröffentlichen.",
@@ -1030,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",
@@ -1320,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",
+21
View File
@@ -842,6 +842,8 @@ export const en: Dictionary = {
glassCountLabel: "Number of jars",
remainingGlassesLabel: "Remaining stock",
imagesLabel: "Batch images",
image1Label: "Image 1 — left tile on the batch page",
image2Label: "Image 2 — right tile on the batch page (location)",
save: "Save",
},
trackOrderPage: {
@@ -921,6 +923,8 @@ export const en: Dictionary = {
locationLabel: "Location",
descriptionLabel: "Description",
imagesLabel: "Harvest images",
image1Label: "Image 1 — harvest overview and left tile on the batch page",
image2Label: "Image 2 — right tile on the batch page (location)",
isPublishedLabel: "Publicly visible",
isPublishedHint: "Controls whether this harvest shows up on the website (shop, /ernten).",
draftNotice: "This harvest is currently only visible in the admin area (e.g. imported from ApiCore). Please review the details, then publish it.",
@@ -1030,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",
@@ -1320,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",
+18
View File
@@ -544,6 +544,8 @@ export interface Dictionary {
glassCountLabel: string;
remainingGlassesLabel: string;
imagesLabel: string;
image1Label: string;
image2Label: string;
save: string;
};
trackOrderPage: {
@@ -622,6 +624,8 @@ export interface Dictionary {
locationLabel: string;
descriptionLabel: string;
imagesLabel: string;
image1Label: string;
image2Label: string;
isPublishedLabel: string;
isPublishedHint: string;
draftNotice: string;
@@ -730,6 +734,7 @@ export interface Dictionary {
noInquiriesYet: string;
};
adminOrderDetailPage: {
postNumberLabel: string;
backToOrders: string;
saveStatus: string;
giftHeading: string;
@@ -1007,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;
+89
View File
@@ -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([]);
});
});
+212
View File
@@ -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
View File
@@ -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) },
},
+33
View File
@@ -0,0 +1,33 @@
/**
* Reine Leser fuer FormData-Werte — ohne Datenbank- oder Next.js-Bezug,
* damit sie sich unabhaengig testen lassen (siehe
* app/admin/actions/image-slots.test.ts). Die Admin-Actions importieren sie
* weiterhin ueber admin/actions/_shared.ts, das sie re-exportiert.
*/
/** Alle Werte eines wiederholten Feldes, getrimmt, ohne leere Eintraege. */
export function strArray(formData: FormData, key: string): string[] {
return formData
.getAll(key)
.map((value) => String(value).trim())
.filter(Boolean);
}
/**
* Wie strArray, aber POSITIONSERHALTEND: ein leerer Eintrag bleibt als ""
* stehen, statt herauszufallen.
*
* Gedacht fuer Felder, bei denen die Position eine Bedeutung traegt. Chargen-
* und Erntebilder sind genau so ein Fall: Slot 1 fuellt die linke, Slot 2 die
* rechte Kachel auf /charge/[code]. Mit strArray liesse sich das zweite Bild
* gar nicht allein setzen — ohne erstes Bild rueckte das zweite auf Position 0
* und landete in der falschen Kachel.
*
* Nur am Ende stehende leere Slots werden entfernt, damit ein vollstaendig
* leeres Feld weiterhin ein leeres Array ergibt statt ["", ""].
*/
export function strArraySlots(formData: FormData, key: string): string[] {
const slots = formData.getAll(key).map((value) => String(value).trim());
while (slots.length > 0 && slots[slots.length - 1] === "") slots.pop();
return slots;
}
+26 -1
View File
@@ -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<typeof checkoutSchema>;
export const reviewSchema = z.object({