feat(shop): show sale badge and strikethrough price on discounted products

Products with compareAtPrice set now show a "-XX%" badge over the
image plus a struck-through original price, wherever price is
displayed (shop grid, homepage spotlight, cart line items). Also
switched the "inkl. MwSt. zzgl. Versand" rows on Pricing.tsx and
ProductSpotlight from items-center to items-baseline — with a large
price next to small disclosure text, center alignment left the
small text visibly floating above the price's bottom edge.
This commit is contained in:
Marco
2026-07-19 23:31:35 +00:00
parent 2a14e18aa1
commit 7b35b3e55b
5 changed files with 43 additions and 11 deletions
+9 -3
View File
@@ -5,7 +5,7 @@ import Link from "next/link";
import Image from "next/image";
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { formatPrice } from "../../lib/format";
import { formatPrice, discountPercent } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
import { FreeShippingBanner } from "./FreeShippingBanner";
@@ -96,7 +96,9 @@ export function CartContent({
have had room for a real 2-column layout at Tablet widths
anyway). */}
<Reveal className="w-full lg:flex-1 flex flex-col gap-6 items-start bg-bg-base border border-border rounded-md p-6 md:p-8">
{items.map(({ entry, product }, i) => (
{items.map(({ entry, product }, i) => {
const discount = discountPercent(product.price, product.compareAtPrice);
return (
<div key={product.id} className="w-full">
{i > 0 && <div className="h-px bg-border w-full mb-6" />}
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 items-start sm:items-center w-full">
@@ -114,6 +116,9 @@ export function CartContent({
<div className="flex flex-col gap-0.5 items-start">
<p className="text-label text-text-muted">Einzelpreis</p>
<p className="flex items-baseline gap-1.5">
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-body-sm text-text-primary">{formatPrice(product.price)}</span>
<span className="text-label text-text-muted">inkl. MwSt.</span>
</p>
@@ -147,7 +152,8 @@ export function CartContent({
</div>
</div>
</div>
))}
);
})}
<div className="h-px bg-border w-full" />
+10 -4
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
import { AddToCartButton } from "./AddToCartButton";
import { Reveal } from "./Reveal";
import { getSpotlightProduct } from "../lib/payload";
import { formatPrice } from "../lib/format";
import { formatPrice, discountPercent } from "../lib/format";
/**
* Product teaser for whichever product is marked `spotlight` in Payload
@@ -26,6 +26,7 @@ export async function ProductSpotlight() {
if (!product) return null;
const image = product.spotlightImage || product.image;
const discount = discountPercent(product.price, product.compareAtPrice);
return (
<section className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
@@ -38,6 +39,11 @@ export async function ProductSpotlight() {
sizes="(min-width: 768px) 380px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)}
</div>
<div className="flex flex-col gap-4 items-start flex-1 min-w-0 w-full">
@@ -51,9 +57,9 @@ export async function ProductSpotlight() {
<p className="text-body text-text-body">
{product.spotlightText || product.description}
</p>
<div className="flex gap-2 items-center">
{product.compareAtPrice && product.compareAtPrice > product.price && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice)}</p>
<div className="flex gap-2 items-baseline">
{discount !== null && (
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">inkl. MwSt. zzgl. Versand</p>
+9
View File
@@ -10,6 +10,15 @@ export function formatPrice(value: number): string {
return `${value.toFixed(2).replace(".", ",")}`;
}
// null when there's nothing to advertise — compareAtPrice unset, or set
// but not actually higher than price (e.g. cleared back down without
// nulling the field). Callers use this both to gate whether to render
// strikethrough/badge UI at all and to fill in the "-XX%" label.
export function discountPercent(price: number, compareAtPrice: number | null): number | null {
if (compareAtPrice === null || compareAtPrice <= price) return null;
return Math.round((1 - price / compareAtPrice) * 100);
}
const MONTHS_DE = [
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
+14 -3
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import Image from "next/image";
import { getProducts } from "../../lib/payload";
import { formatPrice } from "../../lib/format";
import { formatPrice, discountPercent } from "../../lib/format";
import { RevealGroup, RevealItem } from "../../components/Reveal";
import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
@@ -22,7 +22,9 @@ export async function ProductGrid() {
return (
<section className="w-full bg-bg-base flex flex-col pb-16 md:pb-20 px-[var(--layout-padding-x)]">
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
{products.map((product) => (
{products.map((product) => {
const discount = discountPercent(product.price, product.compareAtPrice);
return (
<RevealItem
key={product.id}
className="group md: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"
@@ -35,6 +37,11 @@ export async function ProductGrid() {
sizes="(min-width: 768px) 25vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{discount !== null && (
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
-{discount}%
</span>
)}
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
<p
@@ -44,6 +51,9 @@ export async function ProductGrid() {
{product.name}
</p>
<p className="flex items-baseline gap-1.5">
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
<span className="text-label text-text-muted">inkl. MwSt.</span>
</p>
@@ -66,7 +76,8 @@ export async function ProductGrid() {
<AddToCartInlineButton id={product.id} />
</div>
</RevealItem>
))}
);
})}
</RevealGroup>
</section>
);
+1 -1
View File
@@ -55,7 +55,7 @@ export async function Pricing() {
</div>
<div className="flex flex-col gap-3 items-start w-full lg:w-[18.75rem] lg:shrink-0">
<div className="flex gap-2 items-center">
<div className="flex gap-2 items-baseline">
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">inkl. MwSt. zzgl. Versand</p>
</div>