feat: Packstation ueber DHL-Standortsuche als Lieferziel waehlbar
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m8s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m8s
Im Checkout laesst sich unter "Versand" jetzt zwischen der eigenen
Anschrift und einer DHL-Packstation waehlen. Postleitzahl eingeben,
suchen, Packstation aus der Trefferliste anklicken, DHL-Postnummer
eintragen — die Auswahl wird an der Bestellung gespeichert.
Aufbau:
- lib/dhl-locations.ts: Client fuer die DHL Location Finder Unified API
(GET /find-by-address). Bewusst getrennt von lib/dhl.ts — das ist ein
anderer DHL-Dienst mit anderer Authentifizierung: nur der API-Key im
Header, kein OAuth und kein Geschaeftskundenvertrag. Die
Packstation-Auswahl funktioniert damit auch, solange die
Versandmarken-Erstellung noch nicht eingerichtet ist.
- api/shop/packstationen: serverseitiger Zwischenschritt, damit der
DHL-API-Key nicht ins Browser-Bundle geraet. Streng rate-limitiert
(15/Minute statt 60 wie die Produktsuche) und mit 15-Minuten-Cache:
dahinter steht ein FREMDES Kontingent von 500 Aufrufen pro Tag, das ein
ungebremstes Suchfeld an einem Nachmittag aufbrauchen koennte.
- Order/Address: packstationNumber + postNumber (Migration
20260915222938_add_packstation_delivery, beide nullable).
- checkoutSchema: Postnummer als 6-10 Ziffern, plus zwei refine-Regeln —
ohne Postnummer keine Packstation (DHL verlangt sie zwingend), und eine
Packstation nur bei Versand, nicht bei Abholung.
Zwei Entscheidungen, die Folgearbeit ersparen:
- Bei Packstation-Auswahl tragen street/zip/city zusaetzlich die lesbare
Anschrift ("Packstation 145", PLZ, Ort). Dadurch zeigen Rechnung,
Bestaetigungsmail, Sendungsverfolgung und Admin-Ansicht ohne jede
Aenderung etwas Sinnvolles; packstationNumber/postNumber tragen daneben
das, was DHL technisch braucht.
- lib/dhl.ts baut daraus einen Locker-Empfaenger statt einer
Hausanschrift. DHL kennt fuer den Empfaenger zwei sich ausschliessende
Formen; eine Packstation als Strassentext zu schicken ergaebe eine Marke
auf eine Anschrift, die es nicht gibt.
Die Option erscheint nur, wenn DHL_API_KEY gesetzt ist — sonst wuerde
Kundschaft in eine Suche laufen, die immer fehlschlaegt.
Geprueft: sechs Tests fuer das Umsetzen der DHL-Antwort (die
Packstation-Nummer steht in location.keywordId, NICHT in
location.ids[0].locationId — letzteres ist ein Systemcode, mit dem weder
Kundschaft noch DHL etwas anfangen kann). Dazu am laufenden
Produktions-Build: ohne Key liefert der Endpunkt 503 und der Checkout
zeigt die Option gar nicht; mit Key 400 bei ungueltiger PLZ und ein echter
Aufruf an api.dhl.com, der mit 401 quittiert wird — URL, Header und
Anfrageformat stimmen also. Im Browser durchgeklickt: Umschalten blendet
das Strassenfeld aus und Postnummer samt Suche ein, die Suche ruft die
Route und zeigt die Fehlermeldung an.
Feldnamen beider DHL-APIs vorab gegen developer.dhl.com geprueft. Ein
Lauf gegen einen echten Schluessel steht noch aus.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
ff365c3cb1
commit
9dd71ffb9a
16 files changed
+757
-8
No files matched your search
@@ -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()}
|
||||
/>;
|
||||
}
|
||||
Reference in new issue
Block a user