Fix navbar/discount/invoice bugs from manual QA, add VAT breakdown, shipping-address override, checkout persistence, redesigned mobile menu
Bug fixes:
- Navbar login/logout state now updates immediately (custom ep-auth-changed
event) instead of requiring a hard reload
- Status-change email links were broken by an un-encoded "#" in the order
number; fixed for all 4 status emails
- Cart discount code: manual input field restored (was removed entirely)
- Quote-label underline now scales with the label's actual text width
- Number Ranges admin list now shows the invoice prefix/counter columns
Pricing & VAT:
- Prices show the real per-product VAT rate ("inkl. X% MwSt.") instead of
a generic disclosure
- Cart/checkout/confirmation totals show the actual € amount of VAT
included, broken down per rate when a cart spans more than one
(new lib/taxBreakdown.ts, shared with the invoice PDF's own math)
- Account order pages gained product thumbnails and the same VAT breakdown
Low-stock warning: a "Nur noch wenige verfügbar" badge/hint across the
shop grid, spotlight, and add-to-cart variant pickers, driven by the
existing lowStockThreshold field (still never exposes raw stock counts).
Invoice PDFs: product thumbnails on every line item, a plain "Netto"
label (rate was redundant, already stated on the MwSt. line below), no
more duplicate USt-IdNr. in the header, and — for a Stornorechnung
specifically — an explicit "Versand" line that was previously only
folded silently into the tax totals.
Checkout:
- Optional deviating shipping address (separate from the billing address
used for the invoice), with its own toggle + address form
- Full checkout draft persistence (name/address/shipping/payment
selections) survives navigating away and back, via localStorage
- Invoice PDF shows a third "Lieferadresse" block when the shipping
address differs from billing
Mobile navigation: fullscreen panel with a circular reveal animation from
the hamburger's corner, replacing the old in-flow accordion drawer; no
login CTA inside it (redundant with the always-visible header icon).
Admin-facing (Payload backend, mirrored where the frontend has a ported
copy of the same renderer): dashboard rebuilt as individual cards, split
into 3 task queues (received/processing/returns) instead of 2, revenue
and order counts now exclude cancelled/returned orders immediately, and
the low-stock alert links to the specific affected product(s) instead of
the unfiltered list. A new immediate email notifies the shop owner the
moment an order comes in, instead of only via the daily digest.
Testimonials admin list now groups by page instead of interleaving all
three grids' entries. ~45 English admin field descriptions translated to
German for consistency.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "../../../components/Reveal";
|
||||
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 } from "../../../lib/payload";
|
||||
import { computeTaxBreakdown } from "../../../lib/taxBreakdown";
|
||||
import { buildTrackingUrl, CARRIER_LABELS } from "../../../lib/tracking";
|
||||
import { OrderActionButton } from "./components/OrderActionButton";
|
||||
import { OrderStatusBadge } from "../../components/OrderStatusBadge";
|
||||
@@ -26,7 +30,13 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
order.deliveryMethod === "address"
|
||||
? order.street
|
||||
: `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`;
|
||||
const shippingAddress =
|
||||
order.shippingDeliveryMethod === "packstation"
|
||||
? `Packstation ${order.shippingPackstationNumber} · Postnummer ${order.shippingPostNumber}`
|
||||
: order.shippingStreet;
|
||||
const action = customerOrderAction(order.status);
|
||||
const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
|
||||
const taxBreakdown = computeTaxBreakdown(order.items, order.subtotal, order.discountAmount, order.shippingCost);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -72,7 +82,11 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="text-label text-text-muted">Lieferadresse</p>
|
||||
{/* Labeled "Rechnungsadresse" only once there's an actual
|
||||
second (shipping) address to distinguish it from — the
|
||||
common case (no override) keeps the original "Lieferadresse"
|
||||
label, since that's exactly what this address still is. */}
|
||||
<p className="text-label text-text-muted">{order.hasDifferentShippingAddress ? "Rechnungsadresse" : "Lieferadresse"}</p>
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.customerFirstName} {order.customerLastName}
|
||||
</p>
|
||||
@@ -82,14 +96,33 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{order.hasDifferentShippingAddress && (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="text-label text-text-muted">Lieferadresse</p>
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.shippingFirstName} {order.shippingLastName}
|
||||
</p>
|
||||
<p className="text-body-sm text-text-primary">{shippingAddress}</p>
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.shippingZip} {order.shippingCity}, {order.shippingCountry}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full bg-bg-base border border-border rounded-md p-6 flex flex-col gap-3">
|
||||
{order.items.map((item, i) => (
|
||||
{order.items.map((item, i) => {
|
||||
const imageUrl = imagesByProductId.get(item.product);
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-4 w-full">
|
||||
<div className="relative size-16 shrink-0 rounded-sm overflow-hidden bg-bg-muted">
|
||||
{imageUrl && <Image src={imageUrl} alt="" fill sizes="64px" className="object-cover" />}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-0.5">
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{item.quantity} × {item.productName}
|
||||
{item.variantName ? ` (${item.variantName})` : ""}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">inkl. {item.taxRatePercent}% MwSt.</p>
|
||||
{item.bundleContents && <p className="text-label text-text-muted">{item.bundleContents}</p>}
|
||||
{item.returnQuantity > 0 && (
|
||||
<p className="text-label text-text-muted">davon {item.returnQuantity} zurückgesendet</p>
|
||||
@@ -97,7 +130,8 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary">{formatPrice(item.quantity * item.unitPrice)}</p>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
@@ -123,12 +157,15 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
<div className="flex items-center w-full">
|
||||
<span className="font-semibold text-h4 text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Gesamtsumme
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(order.total)}</span>
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="font-semibold text-h4 text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Gesamtsumme
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(order.total)}</span>
|
||||
</div>
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { formatPrice, formatDate } from "../../lib/format";
|
||||
import { getSessionCustomer, getCustomerOrders } from "../../lib/customerAuth";
|
||||
import { getProductImagesByIds } from "../../lib/payload";
|
||||
import { OrderStatusBadge } from "../components/OrderStatusBadge";
|
||||
import { LogoutButton } from "../components/LogoutButton";
|
||||
|
||||
// Caps how many of an order's items get a thumbnail before folding the
|
||||
// rest into a "+N" pill — a row here is one line in a list, not a full
|
||||
// receipt (that's the detail page), so it stays a glance-able preview.
|
||||
const MAX_THUMBNAILS = 4;
|
||||
|
||||
// robots: noindex — account area, same reasoning as /checkout.
|
||||
export const metadata: Metadata = {
|
||||
title: "Meine Bestellungen",
|
||||
@@ -23,6 +30,7 @@ export default async function KontoBestellungenPage() {
|
||||
if (!session) redirect("/konto/login");
|
||||
|
||||
const orders = await getCustomerOrders(session.token, session.customer.id);
|
||||
const imagesByProductId = await getProductImagesByIds(orders.flatMap((order) => order.productIds));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -39,12 +47,31 @@ export default async function KontoBestellungenPage() {
|
||||
<p className="text-body text-text-muted">Du hast noch keine Bestellung aufgegeben.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{orders.map((order) => (
|
||||
{orders.map((order) => {
|
||||
const thumbnails = order.productIds.slice(0, MAX_THUMBNAILS).map((id) => imagesByProductId.get(id));
|
||||
const overflow = order.productIds.length - MAX_THUMBNAILS;
|
||||
return (
|
||||
<Link
|
||||
key={order.orderNumber}
|
||||
href={`/konto/bestellungen/${encodeURIComponent(order.orderNumber)}`}
|
||||
className="flex flex-wrap items-center gap-4 w-full bg-bg-base border border-border rounded-md p-6 hover:border-brand transition-colors"
|
||||
>
|
||||
<div className="flex -space-x-2 shrink-0">
|
||||
{thumbnails.map((url, i) =>
|
||||
url ? (
|
||||
<div key={i} className="relative size-11 rounded-sm overflow-hidden border-2 border-bg-base">
|
||||
<Image src={url} alt="" fill sizes="44px" className="object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div key={i} className="size-11 rounded-sm bg-bg-muted border-2 border-bg-base" />
|
||||
),
|
||||
)}
|
||||
{overflow > 0 && (
|
||||
<div className="relative size-11 rounded-sm border-2 border-bg-base bg-bg-muted flex items-center justify-center">
|
||||
<span className="text-label font-bold text-text-muted">+{overflow}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-label text-text-muted">Bestellnummer</p>
|
||||
<p className="font-bold text-body-sm text-text-primary">{order.orderNumber}</p>
|
||||
@@ -66,7 +93,8 @@ export default async function KontoBestellungenPage() {
|
||||
<p className="font-bold text-body-sm text-text-primary">{formatPrice(order.total)}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { dispatchAuthChanged } from "../../lib/auth";
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/account/logout", { method: "POST" });
|
||||
dispatchAuthChanged();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../../components/Reveal";
|
||||
import { mergeServerCartIntoLocal } from "../../../lib/cart";
|
||||
import { dispatchAuthChanged } from "../../../lib/auth";
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
@@ -30,6 +31,7 @@ export function LoginForm() {
|
||||
return;
|
||||
}
|
||||
await mergeServerCartIntoLocal();
|
||||
dispatchAuthChanged();
|
||||
router.push("/konto/bestellungen");
|
||||
router.refresh();
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user