Compare commits

...

2 Commits

Author SHA1 Message Date
Marco 8a4170a1e6 Add DHL checkout integrations (autocomplete, postnummer, return label)
Wires the new backend DHL endpoints into checkout: an address-autocomplete
dropdown on the street fields, live Postnummer validation for Packstation
delivery, and a return-label download link on the order-detail page.
Proxied through Next.js API routes since DHL credentials are tenant-
specific and CheckoutContent is a Client Component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:01:05 +00:00
Marco 51e4860fe3 Add spotlight wishlist toggle and shop filter/grid polish
Per-product spotlightShowWishlist opt-in (independent of the global
wishlistEnabled toggle), a reveal-on-hover WishlistButton variant for
multi-card grids (avoids a heart on every card reading as visual noise),
and related PriceRangeFilter/ProductGrid/CustomSelect adjustments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:00:55 +00:00
15 changed files with 477 additions and 80 deletions
+32
View File
@@ -2313,6 +2313,38 @@ features requires the matching `CompanySettings` toggle to actually be
switched on before it shows up anywhere in the frontend — if a feature
"isn't showing," check that first.
## DHL checkout integrations (2026-07-31)
Three checkout-facing pieces added, each invisible unless the tenant's
backend `dhl-settings` has the matching toggle on (checked indirectly —
the calls below 404 gracefully when a tenant hasn't activated a given DHL
sub-integration, same "no half-built UI" convention as the feature
toggles above). Full backend-side documentation:
`docker/payload`'s `src/lib/shipping/README.md`.
- **`app/lib/shippingDhl.ts`** — thin client for the backend's 2 public
DHL endpoints (Postnummer validation, DataFactory address autocomplete).
Own copy, no shared package — same convention as `app/lib/tracking.ts`.
- **`app/api/checkout/validate-dhl-postnumber/route.ts`** and
**`app/api/checkout/autocomplete-address/route.ts`** — Next.js API
routes proxying to the backend. Required because `CheckoutContent.tsx`
is a Client Component and DHL credentials are tenant-specific (live in
Payload) — unlike VIES (`validate-vat/route.ts`), the browser can never
call Payload's DHL endpoint directly.
- **`app/checkout/components/AddressAutocomplete.tsx`** — wraps the
billing and shipping street inputs with a debounced DHL DataFactory
suggestion dropdown; selecting a suggestion also fills zip/city.
- **Postnummer live validation** — `CheckoutContent.tsx`'s
`handlePostNumberBlur` (mirrors `handleVatIdBlur`'s shape/status-state
pattern) checks the Packstation Postnummer against DHL on blur, once the
existing format check (610 digits) already passes.
- **Return-label download** — `/konto/bestellungen/[orderNumber]` shows
a "Retourenschein herunterladen" link once the backend has generated a
DHL return label for that order (`order.dhlReturnLabelMedia`, resolved
via the new `getMediaUrlById()` in `app/lib/payload.ts` — the order
fetch itself stays `depth=0` for unrelated reasons, see
`getCustomerOrderDetail`'s own comment).
## Related design source
- `~/dev/einfach-produktiv/mockups/` — Figma-stage mockup PNGs
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { autocompleteDhlAddress } from "../../../lib/shippingDhl";
// Proxies the checkout's address-autocomplete input through to Payload's
// DHL DataFactory endpoint — same reasoning as validate-dhl-postnumber's
// own route: tenant DHL credentials must never reach the browser.
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get("query") ?? "";
const suggestions = await autocompleteDhlAddress(query);
return NextResponse.json({ ok: true, suggestions });
}
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { validateDhlPostNumber } from "../../../lib/shippingDhl";
// Called from CheckoutContent.tsx's Postnummer field blur (Packstation
// delivery). Proxied through this Next.js route rather than fetched
// directly from the client the way VIES is (see validate-vat/route.ts) —
// DHL credentials are tenant-specific and live in Payload, unlike VIES's
// public EU endpoint, so the browser must never call Payload's DHL
// endpoint (or hold its own copy of tenant credentials) directly.
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const postNumber = typeof body?.postNumber === "string" ? body.postNumber : "";
const firstName = typeof body?.firstName === "string" ? body.firstName : "";
const lastName = typeof body?.lastName === "string" ? body.lastName : "";
if (!postNumber || !firstName || !lastName) {
return NextResponse.json({ ok: false, reason: "Postnummer, Vorname und Nachname sind erforderlich." }, { status: 400 });
}
const result = await validateDhlPostNumber({ postNumber, firstName, lastName });
return NextResponse.json(result);
}
@@ -0,0 +1,112 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { autocompleteDhlAddress, type DhlAddressSuggestion } from "../../lib/shippingDhl";
// Wraps a plain street-address input with a DHL DataFactory suggestion
// dropdown. Completely invisible/inert when the tenant doesn't have
// autocomplete activated (dhl-settings.autocompleteEnabled off) — the proxy
// route just returns an empty suggestions array in that case (see
// app/api/checkout/autocomplete-address/route.ts), so this degrades to a
// plain text input with no dropdown ever appearing, same "no half-built UI
// when a feature is off" convention as WishlistButton/searchEnabled.
//
// Styling duplicates FormField's classes (defined locally in
// CheckoutContent.tsx, not exported) rather than importing it, to keep this
// component usable on its own.
export function AddressAutocomplete({
label,
name,
value,
onChange,
onBlur,
onSelectSuggestion,
error,
placeholder,
autoComplete,
required,
wrapperClassName = "flex-1 min-w-0",
}: {
label: string;
name?: string;
value: string;
onChange: (value: string) => void;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
onSelectSuggestion: (suggestion: DhlAddressSuggestion) => void;
error?: string;
placeholder?: string;
autoComplete?: string;
required?: boolean;
wrapperClassName?: string;
}) {
const [suggestions, setSuggestions] = useState<DhlAddressSuggestion[]>([]);
const [open, setOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (value.trim().length < 3) {
setSuggestions([]);
return;
}
debounceRef.current = setTimeout(async () => {
const res = await fetch(`/api/checkout/autocomplete-address?query=${encodeURIComponent(value)}`);
const data = await res.json().catch(() => ({ ok: false, suggestions: [] }));
setSuggestions(data.ok ? data.suggestions : []);
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return (
<label className={`relative flex flex-col gap-2 items-start ${wrapperClassName}`}>
<span className="text-label text-text-muted">{label}</span>
<input
name={name}
type="text"
value={value}
onChange={(e) => {
onChange(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
// Delayed so a click on a suggestion below (which itself fires a
// blur first) still registers before the dropdown unmounts.
onBlur={(e) => {
setTimeout(() => setOpen(false), 150);
onBlur?.(e);
}}
placeholder={placeholder}
autoComplete={autoComplete}
required={required}
aria-invalid={error ? true : undefined}
className={`w-full border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors ${
error ? "border-red-600" : "border-border"
}`}
/>
{error && <span className="text-label text-red-600">{error}</span>}
{open && suggestions.length > 0 && (
<ul className="absolute top-full left-0 right-0 z-10 mt-1 max-h-60 overflow-y-auto rounded-sm border border-border bg-background shadow-lg">
{suggestions.map((s, i) => (
<li key={i}>
<button
type="button"
onClick={() => {
onChange(`${s.street}${s.houseNumber ? ` ${s.houseNumber}` : ""}`);
onSelectSuggestion(s);
setOpen(false);
}}
className="w-full text-left px-4 py-2 text-body-sm text-text-primary hover:bg-brand/10 transition-colors"
>
{s.street}
{s.houseNumber ? ` ${s.houseNumber}` : ""}, {s.zip} {s.city}
</button>
</li>
))}
</ul>
)}
</label>
);
}
+78 -21
View File
@@ -13,6 +13,7 @@ import { formatPrice } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { CustomSelect } from "../../components/CustomSelect";
import { VersandModal } from "../../components/VersandModal";
import { AddressAutocomplete } from "./AddressAutocomplete";
import { VatBreakdown } from "../../components/VatBreakdown";
import { CheckoutSteps } from "../../components/CheckoutSteps";
import { ORDER_KEY, PENDING_ORDER_KEY, type OrderSnapshot } from "../../lib/order";
@@ -279,6 +280,7 @@ export function CheckoutContent({
// this exact same VIES check server-side at submit time regardless —
// this state is a preview, never the source of truth.
const [vatIdViesStatus, setVatIdViesStatus] = useState<"idle" | "checking" | "valid" | "invalid" | "unavailable">("idle");
const [dhlPostNumberStatus, setDhlPostNumberStatus] = useState<"idle" | "checking" | "valid" | "invalid" | "unavailable">("idle");
// Flips true only after the hydration effect's setState calls have
// actually landed in a render — gates the write-back effect below so it
// never fires with the pre-hydration defaults first and briefly
@@ -472,6 +474,42 @@ export function CheckoutContent({
}
}
// Live-checks the Packstation Postnummer against DHL, mirroring
// handleVatIdBlur's shape. `ok: false` from the proxy route covers both
// "DHL unreachable" and "this tenant doesn't have Postnummer-validation
// activated" (dhl-settings.postnummerEnabled off) — the latter is the
// common case for shops without a DHL integration, so it silently falls
// back to idle (format-only, already checked above) rather than showing
// an alarming "currently unavailable" message for a feature that was
// simply never turned on.
async function handlePostNumberBlur(e: React.FocusEvent<HTMLInputElement>) {
const input = e.target;
const value = input.value;
const formatError = validatePostNumber(value);
setFieldError("shippingPostNumber", formatError, input);
if (!value.trim() || formatError || !shippingFirstName.trim() || !shippingLastName.trim()) {
setDhlPostNumberStatus("idle");
return;
}
setDhlPostNumberStatus("checking");
try {
const res = await fetch("/api/checkout/validate-dhl-postnumber", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ postNumber: value, firstName: shippingFirstName, lastName: shippingLastName }),
});
const data = await res.json();
if (!data.ok) {
setDhlPostNumberStatus("idle");
return;
}
setDhlPostNumberStatus(data.valid ? "valid" : "invalid");
if (!data.valid) input.focus();
} catch {
setDhlPostNumberStatus("idle");
}
}
// Logs into an existing account inline, without leaving /checkout —
// router.refresh() re-runs the page's Server Component, which re-reads
// the now-set session cookie and passes the resolved customerEmail back
@@ -977,13 +1015,16 @@ export function CheckoutContent({
Rechnungsadresse (an invoice needs a real postal address).
Packstation is only ever offered below, in the optional
"Abweichende Lieferadresse" section's own Lieferart toggle. */}
<FormField
<AddressAutocomplete
label="Straße und Hausnummer"
name="street"
type="text"
value={street}
onChange={(e) => setStreet(e.target.value)}
onChange={setStreet}
onBlur={(e) => setFieldError("street", validateRequired("Straße und Hausnummer", e.target.value), e.target)}
onSelectSuggestion={(s) => {
setZip(s.zip);
setCity(s.city);
}}
error={fieldErrors.street}
placeholder="Musterstraße 1"
autoComplete="street-address"
@@ -1115,12 +1156,15 @@ export function CheckoutContent({
</div>
</div>
{shippingDeliveryMethod === "address" ? (
<FormField
<AddressAutocomplete
label="Straße und Hausnummer"
type="text"
value={shippingStreet}
onChange={(e) => setShippingStreet(e.target.value)}
onChange={setShippingStreet}
onBlur={(e) => setFieldError("shippingStreet", validateRequired("Straße und Hausnummer", e.target.value), e.target)}
onSelectSuggestion={(s) => {
setShippingZip(s.zip);
setShippingCity(s.city);
}}
error={fieldErrors.shippingStreet}
placeholder="Musterstraße 1"
autoComplete="off"
@@ -1144,21 +1188,34 @@ export function CheckoutContent({
title="Packstationsnummer muss aus 1 bis 3 Ziffern bestehen (1999)."
required
/>
<FormField
label="Postnummer"
type="text"
value={shippingPostNumber}
onChange={(e) => setShippingPostNumber(e.target.value)}
onBlur={(e) => setFieldError("shippingPostNumber", validatePostNumber(e.target.value), e.target)}
error={fieldErrors.shippingPostNumber}
inputMode="numeric"
placeholder="1234567890"
autoComplete="off"
pattern="\d{6,10}"
maxLength={10}
title="Postnummer muss aus 6 bis 10 Ziffern bestehen."
required
/>
<div className="flex-1 min-w-0 flex flex-col gap-1">
<FormField
label="Postnummer"
type="text"
value={shippingPostNumber}
onChange={(e) => setShippingPostNumber(e.target.value)}
onBlur={handlePostNumberBlur}
error={fieldErrors.shippingPostNumber}
inputMode="numeric"
placeholder="1234567890"
autoComplete="off"
pattern="\d{6,10}"
maxLength={10}
title="Postnummer muss aus 6 bis 10 Ziffern bestehen."
required
wrapperClassName="w-full"
/>
{/* Silent when idle covers both "not yet checked" and
"this shop has no DHL Postnummer-validation active",
same reasoning as handlePostNumberBlur's own comment. */}
<p className="text-label text-text-muted min-h-[1.05rem]">
{dhlPostNumberStatus === "checking" && "Postnummer wird bei DHL geprüft…"}
{dhlPostNumberStatus === "valid" && <span className="text-success"> Postnummer bestätigt</span>}
{dhlPostNumberStatus === "invalid" && (
<span className="text-red-600">DHL konnte Postnummer/Name-Kombination nicht bestätigen.</span>
)}
</p>
</div>
</div>
)}
<div className="flex flex-col sm:flex-row gap-4 w-full">
+11 -1
View File
@@ -67,6 +67,16 @@ export function CustomSelect({
listRef.current?.querySelector<HTMLElement>(`[data-index="${highlighted}"]`)?.scrollIntoView({ block: "nearest" });
}, [open, highlighted]);
// Keyboard/focus-driven close: Tabbing (or programmatically moving
// focus) away from the trigger+list entirely used to leave the panel
// open forever — the mousedown-outside listener above only ever reacts
// to a mouse click, not focus leaving via Tab. `relatedTarget` is where
// focus is headed; null on some browsers when it lands outside the
// document/on a non-focusable element, which should also close.
function onBlur(e: React.FocusEvent) {
if (!rootRef.current?.contains(e.relatedTarget as Node)) setOpen(false);
}
function select(index: number) {
onChange(allOptions[index].value);
setOpen(false);
@@ -96,7 +106,7 @@ export function CustomSelect({
}
return (
<div ref={rootRef} className={`relative w-full ${fullWidth ? "" : "sm:w-auto"}`}>
<div ref={rootRef} onBlur={onBlur} className={`relative w-full ${fullWidth ? "" : "sm:w-auto"}`}>
<button
type="button"
aria-haspopup="listbox"
+7 -2
View File
@@ -2,7 +2,8 @@ import Image from "next/image";
import Link from "next/link";
import { AddToCartButton } from "./AddToCartButton";
import { Reveal } from "./Reveal";
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
import { WishlistButton } from "./WishlistButton";
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled } from "../lib/payload";
import { formatPrice, discountPercent } from "../lib/format";
import { effectiveTaxRate } from "../lib/cartTotals";
@@ -23,11 +24,12 @@ import { effectiveTaxRate } from "../lib/cartTotals";
* see Products.ts), not duplicated here as hardcoded literals.
*/
export async function ProductSpotlight() {
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
const [product, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled] = await Promise.all([
getSpotlightProduct(),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
getWishlistEnabled(),
]);
if (!product) return null;
@@ -71,6 +73,9 @@ export async function ProductSpotlight() {
</span>
)
)}
{wishlistEnabled && product.spotlightShowWishlist && (
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
)}
</div>
<div className="flex flex-col gap-4 items-start flex-1 min-w-0 w-full">
+19 -1
View File
@@ -14,10 +14,24 @@ export function WishlistButton({
productId,
variant = "",
className = "",
revealOnHover = false,
}: {
productId: number;
variant?: string;
className?: string;
/** false (default): always visible right for single-product contexts
* (ProductSpotlight, /konto/merkliste, the product detail page) where
* there's no "wall of hearts" to thin out. true: invisible until the
* card is hovered/focused, unless the product is already wishlisted (a
* filled heart stays as a permanent status indicator) right for a
* multi-card grid (ProductGrid.tsx), where a heart on every single card
* reads as visual noise (Marco: "sieht man überall Herzen", 2026-07-31).
* Relies on the parent card already carrying `group`/`focus-within`
* (see ProductGrid.tsx) Tailwind's plain `:hover`, so tapping a card
* on touch devices reveals it the same way the existing
* `group-hover:-translate-y-1` card-lift already does, no separate
* touch handling needed. */
revealOnHover?: boolean;
}) {
const { isWishlisted, toggle } = useWishlist();
const [pending, setPending] = useState(false);
@@ -47,7 +61,11 @@ export function WishlistButton({
aria-label={wishlisted ? "Von der Merkliste entfernen" : "Zur Merkliste hinzufügen"}
aria-pressed={wishlisted}
disabled={pending}
className={`flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 backdrop-blur-sm transition-transform active:scale-90 disabled:opacity-60 ${className}`}
className={`flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 backdrop-blur-sm transition-all active:scale-90 disabled:opacity-60 ${
revealOnHover && !wishlisted
? "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100"
: ""
} ${className}`}
>
<svg width="20" height="18" viewBox="0 0 20 18" fill={wishlisted ? "currentColor" : "none"} className={wishlisted ? "text-brand" : "text-text-primary"}>
<path
+11 -1
View File
@@ -7,7 +7,7 @@ import { Footer } from "../../../components/Footer";
import { VatBreakdown } from "../../../components/VatBreakdown";
import { formatPrice, formatDate } from "../../../lib/format";
import { getSessionCustomer, getCustomerOrderDetail, customerOrderAction } from "../../../lib/customerAuth";
import { getProductImagesByIds, getPaymentMethods, groupPaymentMethodsForCheckout } from "../../../lib/payload";
import { getProductImagesByIds, getPaymentMethods, groupPaymentMethodsForCheckout, getMediaUrlById } from "../../../lib/payload";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import { buildTrackingUrl, CARRIER_LABELS } from "../../../lib/tracking";
import { OrderActionButton } from "./components/OrderActionButton";
@@ -48,6 +48,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
: order.shippingStreet;
const action = customerOrderAction(order.status);
const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
const dhlReturnLabel = order.dhlReturnLabelMedia ? await getMediaUrlById(order.dhlReturnLabelMedia) : null;
const taxBreakdown = computeTaxBreakdown(order.items, order.subtotal, order.discountAmount, order.shippingCost);
// Same "Online-Zahlung" grouping/eligibility the switch-to-stripe route
// itself re-checks authoritatively — only offer the button when it
@@ -105,6 +106,15 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
</div>
)}
{dhlReturnLabel && (
<div className="flex flex-col gap-1 w-full">
<p className="text-label text-text-muted">DHL-Retourenschein{order.dhlReturnTrackingNumber ? ` (${order.dhlReturnTrackingNumber})` : ""}</p>
<a href={dhlReturnLabel.url} target="_blank" rel="noopener noreferrer" className="text-body-sm text-brand hover:underline">
Retourenschein herunterladen
</a>
</div>
)}
<div className="flex flex-col gap-1 w-full">
{/* Labeled "Rechnungsadresse" only once there's an actual
second (shipping) address to distinguish it from the
+1
View File
@@ -18,6 +18,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
spotlightHeadline: null,
spotlightText: null,
spotlightImage: null,
spotlightShowWishlist: false,
variants: [],
outOfStock: false,
lowStock: false,
+5
View File
@@ -674,6 +674,11 @@ export type CustomerOrderDetail = CustomerOrder & {
correctionInvoiceIssuedAt: string | null;
carrier: string | null;
trackingNumber: string | null;
// Raw media id, not populated — this fetch stays depth=0 (see this
// function's own comment on why), so the order-detail page resolves the
// actual download URL itself via a separate media lookup when present.
dhlReturnLabelMedia: number | null;
dhlReturnTrackingNumber: string | null;
customerFirstName: string;
customerLastName: string;
customerEmail: string;
+21
View File
@@ -200,6 +200,10 @@ export type Product = {
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: string | null;
// Per-product opt-in for a wishlist heart on the homepage spotlight —
// independent of (in addition to) the global wishlistEnabled toggle,
// which still gates the feature site-wide regardless of this flag.
spotlightShowWishlist: boolean;
// Plain booleans, not the raw stock/threshold numbers — the public API
// has no reason to leak exact stock counts, callers only ever need
// "can this be bought right now". `outOfStock` on the product itself
@@ -249,6 +253,7 @@ type PayloadProduct = {
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: { url: string } | number | null;
spotlightShowWishlist: boolean;
trackInventory: boolean;
stock: number | null;
allowBackorder: boolean;
@@ -312,6 +317,7 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
spotlightText: product.spotlightText || null,
spotlightImage:
typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : null,
spotlightShowWishlist: product.spotlightShowWishlist,
outOfStock: isOutOfStock(product.trackInventory, product.stock, product.allowBackorder),
lowStock: isLowStock(product.trackInventory, product.stock, product.lowStockThreshold),
maxQty: maxPurchasableQty(product.trackInventory, product.stock, product.allowBackorder),
@@ -392,6 +398,21 @@ export async function getProductImagesByIds(ids: number[]): Promise<Map<number,
return map;
}
// Resolves a bare media id to its download URL — used by
// /konto/bestellungen/[orderNumber] for order.dhlReturnLabelMedia, which
// stays a raw id on the order fetch itself (that fetch is deliberately
// depth=0, see getCustomerOrderDetail's own comment) rather than bumping
// that fetch's depth just for this one occasional field. `no-store`, not
// ISR-cached like getProductImagesByIds — a return label is a one-off,
// account-specific document, not shared/reusable content worth caching.
export async function getMediaUrlById(id: number): Promise<{ url: string; filename: string } | null> {
const res = await fetch(`${PAYLOAD_URL}/api/media/${id}`, { cache: "no-store" });
if (!res.ok) return null;
const data: { url?: string; filename?: string } = await res.json();
if (!data.url) return null;
return { url: data.url, filename: data.filename ?? "download.pdf" };
}
// Derived from getProducts() (same 60s-ISR-cached fetch every other
// discovery surface already uses) instead of its own separate Payload
// query — also what lets the auto-spotlight rule below just be a plain
+46
View File
@@ -0,0 +1,46 @@
// Thin client for the backend's DHL custom endpoints (src/lib/endpoints/
// dhlValidatePostNumber.ts, dhlAutocompleteAddress.ts) — own copy per repo,
// same "no shared package yet" convention as app/lib/tracking.ts.
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
export async function validateDhlPostNumber(args: {
postNumber: string;
firstName: string;
lastName: string;
}): Promise<{ ok: true; valid: boolean } | { ok: false; reason: string }> {
const params = new URLSearchParams({ tenantSlug: TENANT_SLUG, ...args });
try {
const res = await fetch(`${PAYLOAD_URL}/api/dhl/validate-post-number?${params}`, {
signal: AbortSignal.timeout(8000),
});
const data = await res.json();
if (!res.ok || !data.ok) return { ok: false, reason: data.reason ?? "Postnummer konnte nicht geprüft werden." };
return { ok: true, valid: Boolean(data.valid) };
} catch {
return { ok: false, reason: "Postnummer-Prüfung ist gerade nicht erreichbar." };
}
}
export type DhlAddressSuggestion = {
street: string;
houseNumber?: string;
zip: string;
city: string;
country: string;
};
export async function autocompleteDhlAddress(query: string): Promise<DhlAddressSuggestion[]> {
if (query.trim().length < 3) return [];
const params = new URLSearchParams({ tenantSlug: TENANT_SLUG, query });
try {
const res = await fetch(`${PAYLOAD_URL}/api/dhl/autocomplete-address?${params}`, {
signal: AbortSignal.timeout(5000),
});
const data = await res.json();
if (!res.ok || !data.ok) return [];
return data.suggestions as DhlAddressSuggestion[];
} catch {
return [];
}
}
+62 -41
View File
@@ -8,7 +8,18 @@ import { useRouter, useSearchParams } from "next/navigation";
// submitted via a small Client Component's router.push. Still a plain
// URL search param underneath (?minPrice=&maxPrice=), so the result stays
// shareable/bookmarkable like every other filter on the site.
export function PriceRangeFilter({ catalogMin, catalogMax }: { catalogMin: number; catalogMax: number }) {
export function PriceRangeFilter({
catalogMin,
catalogMax,
layout = "bar",
}: {
catalogMin: number;
catalogMax: number;
/** "bar" (default): horizontal row, wraps used above the grid at
* <lg (see ProductGrid.tsx's mobile/tablet filter bar). "sidebar":
* stacked vertically to fit the lg:+ left sidebar column instead. */
layout?: "bar" | "sidebar";
}) {
const router = useRouter();
const searchParams = useSearchParams();
const [minPrice, setMinPrice] = useState(searchParams.get("minPrice") ?? "");
@@ -31,50 +42,60 @@ export function PriceRangeFilter({ catalogMin, catalogMax }: { catalogMin: numbe
router.push("/shop");
}
const sidebar = layout === "sidebar";
return (
<form onSubmit={apply} className="flex flex-wrap items-end gap-3 w-full pb-6">
<label className="flex flex-col gap-1">
<span className="text-label text-text-muted">Von</span>
<input
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder={`${catalogMin}`}
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
className="w-24 border border-border rounded-sm px-3 py-2 text-body-sm text-text-primary bg-bg-base outline-none focus:border-brand transition-colors"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-label text-text-muted">Bis</span>
<input
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder={`${catalogMax}`}
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
className="w-24 border border-border rounded-sm px-3 py-2 text-body-sm text-text-primary bg-bg-base outline-none focus:border-brand transition-colors"
/>
</label>
<span className="text-body-sm text-text-muted"></span>
<button
type="submit"
className="px-4 py-2 rounded-sm bg-brand text-body-sm font-bold text-text-primary hover:brightness-95 active:scale-[0.97] transition-all"
>
Anwenden
</button>
{hasFilter && (
<form
onSubmit={apply}
className={sidebar ? "flex flex-col gap-3 items-stretch w-full" : "flex flex-wrap items-end gap-3 w-full pb-6"}
>
{sidebar && <p className="font-bold text-body-sm text-text-primary">Preis</p>}
<div className={sidebar ? "flex items-end gap-3 w-full" : "flex items-end gap-3"}>
<label className={sidebar ? "flex flex-col gap-1 flex-1 min-w-0" : "flex flex-col gap-1"}>
<span className="text-label text-text-muted">Von</span>
<input
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder={`${catalogMin}`}
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
className={`${sidebar ? "w-full" : "w-24"} border border-border rounded-sm px-3 py-2 text-body-sm text-text-primary bg-bg-base outline-none focus:border-brand transition-colors`}
/>
</label>
<label className={sidebar ? "flex flex-col gap-1 flex-1 min-w-0" : "flex flex-col gap-1"}>
<span className="text-label text-text-muted">Bis</span>
<input
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder={`${catalogMax}`}
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
className={`${sidebar ? "w-full" : "w-24"} border border-border rounded-sm px-3 py-2 text-body-sm text-text-primary bg-bg-base outline-none focus:border-brand transition-colors`}
/>
</label>
{!sidebar && <span className="text-body-sm text-text-muted"></span>}
</div>
<div className={sidebar ? "flex flex-col gap-2 items-stretch w-full" : "flex items-center gap-3"}>
<button
type="button"
onClick={reset}
className="text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors"
type="submit"
className={`${sidebar ? "w-full" : ""} px-4 py-2 rounded-sm bg-brand text-body-sm font-bold text-text-primary hover:brightness-95 active:scale-[0.97] transition-all`}
>
Zurücksetzen
Anwenden
</button>
)}
{hasFilter && (
<button
type="button"
onClick={reset}
className={`${sidebar ? "text-center" : ""} text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors`}
>
Zurücksetzen
</button>
)}
</div>
</form>
);
}
+39 -13
View File
@@ -46,18 +46,42 @@ export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?
const maxPrice = shopFilterEnabled && searchParams?.maxPrice ? Number(searchParams.maxPrice) : null;
const products = allActiveProducts.filter((p) => (minPrice === null || p.price >= minPrice) && (maxPrice === null || p.price <= maxPrice));
const hasSidebarFilter = shopFilterEnabled && catalogMin !== catalogMax;
return (
<section className="w-full bg-bg-base flex flex-col pb-16 md:pb-20 px-[var(--layout-padding-x)]">
{shopFilterEnabled && catalogMin !== catalogMax && <PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} />}
{/* <lg: filter (if any) sits as its own bar above the grid same as
before. lg+: it moves into a left sidebar instead (see aside
below), so it's hidden here to avoid rendering twice. */}
{hasSidebarFilter && (
<div className="lg:hidden">
<PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} />
</div>
)}
{products.length === 0 && <p className="text-body text-text-muted pb-6">Keine Produkte in dieser Preisspanne gefunden.</p>}
{/* lg:flex a real left sidebar only once there's an actual filter
to put in it (hasSidebarFilter); with no filter, the grid alone
fills the row exactly as before, no empty reserved column. */}
<div className={hasSidebarFilter ? "lg:flex lg:gap-10 w-full" : "w-full"}>
{hasSidebarFilter && (
<aside className="hidden lg:block lg:w-56 shrink-0">
<PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} layout="sidebar" />
</aside>
)}
{/* 2-up from the mobile breakpoint (sm, 640px) through 1023px was
sm:grid-cols-12 with each card sm:col-span-3 (4-up), too narrow a
card through that tablet range. 4-up now only kicks in at lg
(1024px), true mobile (below sm) unchanged. */}
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full">
{products.map((product) => {
<div className="flex-1 min-w-0 flex flex-col">
{products.length === 0 && <p className="text-body text-text-muted pb-6">Keine Produkte in dieser Preisspanne gefunden.</p>}
{/* 2-up from the mobile breakpoint (sm, 640px) through 1023px was
sm:grid-cols-12 with each card sm:col-span-3 (4-up), too narrow a
card through that tablet range. lg+ is now 3-up (col-span-4 of
12) rather than 4-up narrowed to leave room for the sidebar
filter alongside it (see hasSidebarFilter above); with no
filter active the grid still renders at this same 3-up density,
simplest to keep one fixed lg: density rather than branching
the whole grid on hasSidebarFilter too. */}
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full">
{products.map((product) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
// A varianted product only reads as "ausverkauft" overall once
@@ -73,14 +97,14 @@ export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?
return (
<RevealItem
key={product.id}
className="group lg:col-span-3 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full transition-transform duration-300 hover:-translate-y-1"
className="group lg:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full transition-transform duration-300 hover:-translate-y-1"
>
<div className="relative w-full aspect-[276/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw"
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
{fullyOutOfStock ? (
@@ -95,7 +119,7 @@ export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?
)
)}
{wishlistEnabled && (
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover />
)}
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
@@ -147,8 +171,10 @@ export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?
</div>
</RevealItem>
);
})}
</RevealGroup>
})}
</RevealGroup>
</div>
</div>
</section>
);
}