diff --git a/app/api/account/orders/[orderNumber]/route.ts b/app/api/account/orders/[orderNumber]/route.ts index 433c397..ccdb0c7 100644 --- a/app/api/account/orders/[orderNumber]/route.ts +++ b/app/api/account/orders/[orderNumber]/route.ts @@ -4,13 +4,15 @@ import { getCustomerOrderDetail, requestOrderStatusChange, customerOrderAction, + type CustomerOrderItem, } from "../../../../lib/customerAuth"; // The real security boundary is Orders.ts's beforeChange hook in Payload -// (only `status` can change, only via an allowed transition) — the check -// against customerOrderAction() here is just for a friendlier error -// message than a bare 403 when the button's already stale (e.g. two tabs -// open, order shipped in the meantime). +// (only `status`/`returnReason`/items' `returnQuantity` can change, only +// via an allowed transition) — the checks here are just for a friendlier +// error message than a bare 403 when the request is malformed or stale +// (e.g. two tabs open, order shipped in the meantime, a quantity that no +// longer fits). export async function PATCH(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) { const session = await getSessionCustomer(); if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 }); @@ -21,10 +23,6 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ or if (action !== "cancel" && action !== "request-return") { return NextResponse.json({ ok: false, reason: "Ungültige Aktion." }, { status: 400 }); } - const returnReason = typeof body?.returnReason === "string" ? body.returnReason.trim() : ""; - if (action === "request-return" && !returnReason) { - return NextResponse.json({ ok: false, reason: "Bitte kurz angeben, warum du zurücksenden möchtest." }, { status: 400 }); - } const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber)); if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 }); @@ -32,6 +30,47 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ or return NextResponse.json({ ok: false, reason: "Diese Aktion ist für diese Bestellung gerade nicht möglich." }, { status: 400 }); } - const result = await requestOrderStatusChange(session.token, order.id, action, returnReason || undefined); - return NextResponse.json(result, { status: result.ok ? 200 : 400 }); + if (action === "cancel") { + const result = await requestOrderStatusChange(session.token, order.id, "cancel"); + return NextResponse.json(result, { status: result.ok ? 200 : 400 }); + } + + // request-return: partial returns supported — the client sends + // { product, returnQuantity } per line it wants to return (0 or + // omitted for lines being kept). Reconstruct the order's FULL items + // array here (see requestOrderStatusChange's own comment on why a + // sparse patch doesn't work), validating each requested quantity + // against what was actually ordered. + const returnReason = typeof body?.returnReason === "string" ? body.returnReason.trim() : ""; + if (!returnReason) { + return NextResponse.json({ ok: false, reason: "Bitte kurz angeben, warum du zurücksenden möchtest." }, { status: 400 }); + } + const requestedQuantities = new Map(); + if (Array.isArray(body?.returnItems)) { + for (const line of body.returnItems) { + const product = Number(line?.product); + const returnQuantity = Number(line?.returnQuantity); + if (Number.isFinite(product) && Number.isFinite(returnQuantity) && returnQuantity > 0) { + requestedQuantities.set(product, returnQuantity); + } + } + } + if (requestedQuantities.size === 0) { + return NextResponse.json({ ok: false, reason: "Bitte mindestens einen Artikel mit Menge auswählen." }, { status: 400 }); + } + + const items: CustomerOrderItem[] = order.items.map((item) => { + const requested = requestedQuantities.get(item.product) ?? 0; + if (requested > item.quantity) { + throw Object.assign(new Error("returnQuantity exceeds ordered quantity"), { status: 400 }); + } + return { ...item, returnQuantity: requested }; + }); + + try { + const result = await requestOrderStatusChange(session.token, order.id, "request-return", { returnReason, items }); + return NextResponse.json(result, { status: result.ok ? 200 : 400 }); + } catch { + return NextResponse.json({ ok: false, reason: "Eine der Mengen übersteigt die bestellte Menge." }, { status: 400 }); + } } diff --git a/app/konto/bestellungen/[orderNumber]/components/OrderActionButton.tsx b/app/konto/bestellungen/[orderNumber]/components/OrderActionButton.tsx index 8e47f7e..b530b39 100644 --- a/app/konto/bestellungen/[orderNumber]/components/OrderActionButton.tsx +++ b/app/konto/bestellungen/[orderNumber]/components/OrderActionButton.tsx @@ -4,39 +4,35 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; const LABEL = { cancel: "Bestellung stornieren", "request-return": "Rücksendung anfragen" } as const; -const CONFIRM = { - cancel: "Bestellung wirklich stornieren?", -} as const; -export function OrderActionButton({ orderNumber, action }: { orderNumber: string; action: "cancel" | "request-return" }) { +export type ReturnableItem = { product: number; productName: string; quantity: number }; + +export function OrderActionButton({ + orderNumber, + action, + items, +}: { + orderNumber: string; + action: "cancel" | "request-return"; + items: ReturnableItem[]; +}) { const router = useRouter(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [formOpen, setFormOpen] = useState(false); + const [returnReason, setReturnReason] = useState(""); + // Keyed by product id, string so the input can hold an empty/partial + // value while typing — parsed to a number only on submit. + const [quantities, setQuantities] = useState>({}); - async function handleClick() { - let returnReason: string | undefined; - if (action === "cancel") { - if (!window.confirm(CONFIRM.cancel)) return; - } else { - // A short reason helps quality/assortment decisions later (see the - // Payload README's Orders.ts section) — prompt() rather than a - // custom form, same "plain browser dialog" pattern already used for - // cancel's confirm() above. - const input = window.prompt("Kurz gesagt, warum möchtest du die Bestellung zurücksenden?"); - if (input === null) return; - returnReason = input.trim(); - if (!returnReason) { - setError("Bitte kurz einen Grund angeben."); - return; - } - } + async function submit(body: Record) { setLoading(true); setError(null); try { const res = await fetch(`/api/account/orders/${encodeURIComponent(orderNumber)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action, returnReason }), + body: JSON.stringify(body), }); const data = await res.json(); if (!data.ok) { @@ -51,16 +47,104 @@ export function OrderActionButton({ orderNumber, action }: { orderNumber: string } } - return ( -
+ async function handleCancel() { + if (!window.confirm("Bestellung wirklich stornieren?")) return; + await submit({ action: "cancel" }); + } + + async function handleReturnSubmit() { + const trimmedReason = returnReason.trim(); + if (!trimmedReason) { + setError("Bitte kurz einen Grund angeben."); + return; + } + const returnItems = Object.entries(quantities) + .map(([product, value]) => ({ product: Number(product), returnQuantity: Number(value) || 0 })) + .filter((line) => line.returnQuantity > 0); + if (returnItems.length === 0) { + setError("Bitte mindestens einen Artikel mit Menge auswählen."); + return; + } + await submit({ action: "request-return", returnReason: trimmedReason, returnItems }); + } + + if (action === "cancel") { + return ( +
+ + {error &&

{error}

} +
+ ); + } + + // request-return: a short inline form, not window.prompt() — needs a + // per-item quantity (partial returns are supported, see the Payload + // README's "How a Stornorechnung/Gutschrift relates..." section), which + // a single-line browser prompt can't reasonably capture. + if (!formOpen) { + return ( + ); + } + + return ( +
+

Welche Artikel möchtest du zurücksenden?

+
+ {items.map((item) => ( +
+ {item.productName} + + / {item.quantity} +
+ ))} +
+