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:
Marco
2026-07-22 22:52:15 +00:00
parent 7a9fed6f95
commit 43944d8cc8
39 changed files with 1435 additions and 306 deletions
+74 -2
View File
@@ -176,7 +176,16 @@ export type Product = {
// only matters for a product with no variants; a varianted product's
// buyability is entirely per-variant (see each variant's own flag).
outOfStock: boolean;
variants: { name: string; priceOverride: number | null; outOfStock: boolean }[];
// Derived, like outOfStock — no raw stock count/threshold leaked, callers
// only ever need "should a low-stock hint show for this right now".
lowStock: boolean;
// Per-product override — null means "use the tenant's default rate"
// (CompanySettings.taxRatePercent, fetched separately since it's behind
// an admin-only secret, see getCompanySettings()). Display-only on the
// storefront; the actual rate used for order totals is resolved and
// snapshotted server-side at checkout (api/checkout/route.ts).
taxRatePercent: number | null;
variants: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean }[];
};
type PayloadProduct = {
@@ -198,7 +207,18 @@ type PayloadProduct = {
trackInventory: boolean;
stock: number | null;
allowBackorder: boolean;
variants: { name: string; priceOverride: number | null; trackInventory: boolean; stock: number | null; allowBackorder: boolean }[] | null;
lowStockThreshold: number | null;
taxRatePercent: number | null;
variants:
| {
name: string;
priceOverride: number | null;
trackInventory: boolean;
stock: number | null;
allowBackorder: boolean;
lowStockThreshold: number | null;
}[]
| null;
};
// A product/variant is only actually unbuyable when it opted into
@@ -210,6 +230,13 @@ function isOutOfStock(trackInventory: boolean, stock: number | null, allowBackor
return trackInventory && !allowBackorder && (stock ?? 0) <= 0;
}
// Below the threshold but not already out of stock — out-of-stock gets its
// own distinct "Ausverkauft" badge, a low-stock one on top of that would be
// redundant/contradictory.
function isLowStock(trackInventory: boolean, stock: number | null, threshold: number | null): boolean {
return trackInventory && threshold != null && stock != null && stock > 0 && stock <= threshold;
}
// Shared by getProducts() and getPostBySlug()'s relatedProduct — kept in
// one place instead of duplicating the same field mapping, which is
// exactly the kind of drift this session's Shipping Settings work was
@@ -232,10 +259,13 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
spotlightImage:
typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : null,
outOfStock: isOutOfStock(product.trackInventory, product.stock, product.allowBackorder),
lowStock: isLowStock(product.trackInventory, product.stock, product.lowStockThreshold),
taxRatePercent: product.taxRatePercent ?? null,
variants: (product.variants ?? []).map((v) => ({
name: v.name,
priceOverride: v.priceOverride,
outOfStock: isOutOfStock(v.trackInventory, v.stock, v.allowBackorder),
lowStock: isLowStock(v.trackInventory, v.stock, v.lowStockThreshold),
})),
};
}
@@ -266,6 +296,28 @@ export async function getProductBySlug(slug: string): Promise<Product | null> {
return products.find((p) => p.id === slug) ?? null;
}
// For account order pages — Orders.items only snapshots a numeric
// `product` relationship id (see CustomerOrderItem in lib/customerAuth.ts),
// not an image URL, unlike the checkout/email/invoice paths that resolve
// the image once at order-creation/send time. depth=1 + a single `in`
// query is a plain product-id → image-url lookup, deliberately separate
// from getProducts()'s slug-keyed catalog (an order can reference a
// product that's since been deactivated/deleted, and slugs aren't even
// the key an order item stores).
export async function getProductImagesByIds(ids: number[]): Promise<Map<number, string>> {
const uniqueIds = [...new Set(ids)];
const map = new Map<number, string>();
if (uniqueIds.length === 0) return map;
const params = new URLSearchParams({ "where[id][in]": uniqueIds.join(","), depth: "1", limit: String(uniqueIds.length) });
const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { next: { revalidate: 60 } });
if (!res.ok) return map;
const data: { docs?: { id: number; image: { url: string } | number | null }[] } = await res.json();
for (const doc of data.docs ?? []) {
if (typeof doc.image === "object" && doc.image) map.set(doc.id, doc.image.url);
}
return map;
}
// 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
@@ -707,3 +759,23 @@ export async function getCompanySettings(): Promise<CompanySettings | null> {
const data: { docs?: CompanySettings[] } = await res.json();
return data.docs?.[0] ?? null;
}
// A separate, ISR-cached fetch (unlike getCompanySettings()'s deliberate
// cache: "no-store", where invoice generation needs always-fresh bank
// details/legal footer text) — the storefront's "inkl. X% MwSt." display
// rate only needs the same 60s freshness every other public catalog fetch
// here already has, and only ever needs the one number, not the seller's
// bank details/register info.
export async function getDefaultTaxRatePercent(): Promise<number> {
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getDefaultTaxRatePercent: Payload returned ${res.status} ${res.statusText}`);
return 19;
}
const data: { docs?: { taxRatePercent: number }[] } = await res.json();
return data.docs?.[0]?.taxRatePercent ?? 19;
}