Move low-stock hint to badge, right-align VAT breakdown, gate cart discount field, split billing/shipping delivery method

- Removed the inline "Nur noch wenige verfügbar" text hint from
  AddToCartButton/AddToCartInlineButton (was making card heights vary in
  every grid that renders them — RelatedProducts, ProductSpotlight's CTA
  row) — now only shown via the same image-overlaid pill badge
  Ausverkauft/discount already use (position: absolute, doesn't affect
  layout). Added that badge to RelatedProducts.tsx and todo-cards'
  Pricing.tsx, which didn't have it before.
- RelatedProducts cards now also show "inkl. X% MwSt." (was missing
  entirely)
- VatBreakdown rows are now flex rows with a spacer instead of plain
  text, so every € amount right-aligns to the same edge regardless of
  how many digits the rate itself has (was visibly staggered with mixed
  7%/19% rates)
- Cart's manual discount-code field only renders when Payload actually
  has at least one active code right now (lib/discountServer.ts's new
  hasActiveDiscountCode()) — no point showing an open field that could
  never validate. An already-applied code (e.g. from an older session)
  still always shows its own result row regardless.
- Checkout's "1. Rechnungsadresse" no longer offers a Packstation option
  — a Packstation isn't a valid billing address for an invoice. Only a
  plain street address now; Packstation is only offered on the separate,
  optional "Abweichende Lieferadresse" section, which already had its own
  address/Packstation toggle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-22 23:25:18 +00:00
parent dc6b61324f
commit a50524832e
13 changed files with 175 additions and 141 deletions
+27 -5
View File
@@ -22,6 +22,7 @@ export function CartContent({
freeShippingThreshold,
shippingSettings,
defaultTaxRate,
showDiscountField,
}: {
trustBadges: TrustBadge[];
/** Price of the default (first active, i.e. Standard) ShippingMethod — an
@@ -41,6 +42,13 @@ export function CartContent({
* override taxRatePercent themselves — see lib/cartTotals.ts's
* effectiveTaxRate(). */
defaultTaxRate: number;
/** Whether Payload currently has at least one active discount code at
* all (lib/discountServer.ts's hasActiveDiscountCode()) — no point
* showing an open "enter a code" field when nothing could ever validate
* against it. Only gates the manual-entry form; a code already applied
* (e.g. from an earlier session, or one deactivated after being shared)
* still shows its own result row regardless. */
showDiscountField: boolean;
}) {
const [versandOpen, setVersandOpen] = useState(false);
const cart = useCart();
@@ -275,10 +283,14 @@ export function CartContent({
</div>
)}
{/* Rabattcode — manual input when nothing's applied yet;
once active, just the result + "Entfernen" (also reached
via a direct link with a prefilled code, see the useEffect
above). /checkout mirrors this exact block, sharing state
{/* Rabattcode — manual input when nothing's applied yet AND
Payload actually has at least one active code right now
(showDiscountField — no point offering an open field
that could never validate against anything); once
active, always shows the result + "Entfernen" regardless
of showDiscountField (also reached via a direct link
with a prefilled code, see the useEffect above).
/checkout mirrors this exact block, sharing state
through lib/discount.ts's localStorage store. */}
{discount ? (
<div className="flex flex-col gap-2 w-full">
@@ -295,7 +307,7 @@ export function CartContent({
Entfernen
</button>
</div>
) : (
) : showDiscountField ? (
<form
onSubmit={(e) => {
e.preventDefault();
@@ -324,6 +336,16 @@ export function CartContent({
{discountError && <p className="text-label text-red-600">{discountError}</p>}
{discountLoading && <p className="text-label text-text-muted">Rabattcode wird geprüft</p>}
</form>
) : (
// No manual field to attach an error to (no active codes
// exist at all right now) — but a ?code= URL param can
// still trigger the auto-apply attempt above regardless
// of showDiscountField, so its failure needs somewhere to
// show.
<>
{discountError && <p className="text-label text-red-600 w-full">{discountError}</p>}
{discountLoading && <p className="text-label text-text-muted w-full">Rabattcode wird geprüft</p>}
</>
)}
<div className="flex flex-col gap-0.5 w-full">
+42 -12
View File
@@ -3,7 +3,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { useProducts } from "../../lib/products";
import { formatPrice } from "../../lib/format";
import { formatPrice, discountPercent } from "../../lib/format";
import { effectiveTaxRate } from "../../lib/cartTotals";
import { Reveal } from "../../components/Reveal";
import { AddToCartInlineButton, FEEDBACK_MS } from "../../components/AddToCartInlineButton";
import { useCart } from "../../lib/cart";
@@ -29,7 +30,7 @@ function pickAvailable(allIds: string[], excludeIds: string[], keep: string[], c
return [...keep, ...pickRandom(allIds, [...excludeIds, ...keep], missing)];
}
export function RelatedProducts() {
export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number }) {
const cart = useCart();
const products = useProducts();
// Cart/checkout resolve any product regardless of `active` (see
@@ -112,12 +113,7 @@ export function RelatedProducts() {
</p>
</Reveal>
{/* No separate price-disclosure footnote here — the single
"* inkl. MwSt., zzgl. Versandkosten" note lives directly under
the cart's own product table instead (CartContent.tsx), close
enough on the same page view to cover these cards too.
Plain divs, not RevealGroup/RevealItem — this is the one grid on
{/* Plain divs, not RevealGroup/RevealItem — this is the one grid on
the site whose items get swapped after the initial mount (see
the swap-in-place effect above). RevealItem has no viewport
trigger of its own; it only ever renders visible because it
@@ -128,7 +124,13 @@ export function RelatedProducts() {
scroll-reveal nicety on a list that mutates; a static grid
renders correctly with no animation risk. */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
{displayProducts.map((product, i) => (
{displayProducts.map((product, i) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
// Same "any vs. every" split as ProductGrid.tsx.
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
return (
<div
key={product.id}
className={
@@ -155,6 +157,27 @@ export function RelatedProducts() {
sizes="(min-width: 768px) 320px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{/* Same top-left pill pattern as ProductGrid.tsx/
ProductSpotlight.tsx — position: absolute, so it never
affects this card's height the way the old inline
low-stock text hint under the button used to (that
variability was exactly what broke equal card heights
in this grid). */}
{fullyOutOfStock ? (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
) : 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>
) : (
anyLowStock && (
<span className="absolute top-3 left-3 rounded-full bg-warning px-2.5 py-1 text-label font-bold text-text-on-dark">
Nur noch wenige verfügbar
</span>
)
)}
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-2 w-full">
<p
@@ -163,11 +186,18 @@ export function RelatedProducts() {
>
{product.name}
</p>
<p className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</p>
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} lowStock={product.lowStock} variants={product.variants} />
<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. {taxRate}% MwSt.</span>
</p>
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} variants={product.variants} />
</div>
</div>
))}
);
})}
</div>
</section>
);
+5 -2
View File
@@ -5,6 +5,7 @@ import { RelatedProducts } from "./components/RelatedProducts";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
import { getCartTrustBadges, getShippingMethods, getShippingSettings, getDefaultTaxRatePercent } from "../lib/payload";
import { hasActiveDiscountCode } from "../lib/discountServer";
// robots: noindex — transactional page (mirrors a specific shopper's cart
// contents), per the figma-to-nextjs skill's Step 5 guidance: indexing
@@ -19,11 +20,12 @@ export const metadata: Metadata = {
};
export default async function CartPage() {
const [trustBadges, shippingMethods, shipping, defaultTaxRate] = await Promise.all([
const [trustBadges, shippingMethods, shipping, defaultTaxRate, showDiscountField] = await Promise.all([
getCartTrustBadges(),
getShippingMethods(),
getShippingSettings(),
getDefaultTaxRatePercent(),
hasActiveDiscountCode(),
]);
// The cart doesn't ask which shipping method the shopper wants yet
@@ -53,9 +55,10 @@ export default async function CartPage() {
freeShippingThreshold={freeShippingThreshold}
shippingSettings={shipping}
defaultTaxRate={defaultTaxRate}
showDiscountField={showDiscountField}
/>
</Suspense>
<RelatedProducts />
<RelatedProducts defaultTaxRate={defaultTaxRate} />
<TrustRow />
</main>
<Footer />
+21 -88
View File
@@ -70,7 +70,6 @@ export function CheckoutContent({
const [shippingMethodId, setShippingMethodId] = useState<number | null>(shippingMethods[0]?.id ?? null);
const [paymentMethodId, setPaymentMethodId] = useState<number | null>(paymentMethods[0]?.id ?? null);
const [versandOpen, setVersandOpen] = useState(false);
const [deliveryMethod, setDeliveryMethod] = useState<"address" | "packstation">(savedProfile?.deliveryMethod ?? "address");
const [purchaseError, setPurchaseError] = useState<string | null>(null);
const [purchasing, setPurchasing] = useState(false);
const [showLogin, setShowLogin] = useState(false);
@@ -90,9 +89,10 @@ export function CheckoutContent({
const [firstName, setFirstName] = useState(savedProfile?.firstName ?? "");
const [lastName, setLastName] = useState(savedProfile?.lastName ?? "");
const [email, setEmail] = useState(savedProfile?.email ?? customerEmail ?? "");
// Always a plain street address — a Packstation isn't a valid Rechnungs-
// adresse (an invoice needs a real postal address). Packstation is only
// ever offered on the separate, optional shipping-address override below.
const [street, setStreet] = useState(savedProfile?.street ?? "");
const [packstationNumber, setPackstationNumber] = useState(savedProfile?.packstationNumber ?? "");
const [postNumber, setPostNumber] = useState(savedProfile?.postNumber ?? "");
const [zip, setZip] = useState(savedProfile?.zip ?? "");
const [city, setCity] = useState(savedProfile?.city ?? "");
const [country, setCountry] = useState(savedProfile?.country ?? "Deutschland");
@@ -130,10 +130,7 @@ export function CheckoutContent({
if (draft.firstName) setFirstName(draft.firstName);
if (draft.lastName) setLastName(draft.lastName);
if (draft.email) setEmail(draft.email);
if (draft.deliveryMethod) setDeliveryMethod(draft.deliveryMethod);
if (draft.street) setStreet(draft.street);
if (draft.packstationNumber) setPackstationNumber(draft.packstationNumber);
if (draft.postNumber) setPostNumber(draft.postNumber);
if (draft.zip) setZip(draft.zip);
if (draft.city) setCity(draft.city);
if (draft.country) setCountry(draft.country);
@@ -161,10 +158,7 @@ export function CheckoutContent({
firstName,
lastName,
email,
deliveryMethod,
street,
packstationNumber,
postNumber,
zip,
city,
country,
@@ -187,10 +181,7 @@ export function CheckoutContent({
firstName,
lastName,
email,
deliveryMethod,
street,
packstationNumber,
postNumber,
zip,
city,
country,
@@ -317,10 +308,10 @@ export function CheckoutContent({
// one address-card field that stays uncontrolled/unpersisted (see
// lib/checkoutDraft.ts's own comment on why).
password: customerEmail ? undefined : String(form.get("password") ?? ""),
deliveryMethod,
// Rechnungsadresse is always a plain street address — see the
// street state's own comment on why Packstation isn't offered here.
deliveryMethod: "address" as const,
street: street || undefined,
packstationNumber: packstationNumber || undefined,
postNumber: postNumber || undefined,
zip,
city,
country,
@@ -547,79 +538,21 @@ export function CheckoutContent({
</p>
</div>
)}
{/* Segmented control, same sm:w-[calc(50%-0.5rem)] half-row
width as the field(s) below it — Lieferadresse keeps
Straße und Hausnummer, Packstation swaps it out for
Packstationnummer + Postnummer (DHL's two Packstation
identifiers; there's no house number to give). */}
<div className="w-full sm:w-[calc(50%-0.5rem)] flex flex-col gap-2 items-start">
<span className="text-label text-text-muted">Lieferart</span>
<div className="flex w-full rounded-sm border border-border overflow-hidden">
<button
type="button"
onClick={() => setDeliveryMethod("address")}
aria-pressed={deliveryMethod === "address"}
className={`flex-1 py-3 text-body-sm font-bold transition-colors ${
deliveryMethod === "address"
? "bg-brand text-text-primary"
: "text-text-muted hover:text-text-primary"
}`}
>
Lieferadresse
</button>
<button
type="button"
onClick={() => setDeliveryMethod("packstation")}
aria-pressed={deliveryMethod === "packstation"}
className={`flex-1 py-3 text-body-sm font-bold border-l border-border transition-colors ${
deliveryMethod === "packstation"
? "bg-brand text-text-primary"
: "text-text-muted hover:text-text-primary"
}`}
>
Packstation
</button>
</div>
</div>
{deliveryMethod === "address" ? (
<FormField
label="Straße und Hausnummer"
name="street"
type="text"
value={street}
onChange={(e) => setStreet(e.target.value)}
placeholder="Musterstraße 1"
autoComplete="street-address"
required
wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0"
/>
) : (
<div className="flex flex-col sm:flex-row gap-4 w-full">
<FormField
label="Packstationnummer"
name="packstationNumber"
type="text"
value={packstationNumber}
onChange={(e) => setPackstationNumber(e.target.value)}
inputMode="numeric"
placeholder="123"
autoComplete="off"
required
/>
<FormField
label="Postnummer"
name="postNumber"
type="text"
value={postNumber}
onChange={(e) => setPostNumber(e.target.value)}
inputMode="numeric"
placeholder="1234567"
autoComplete="off"
required
/>
</div>
)}
{/* Always a plain street address — Packstation isn't a valid
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
label="Straße und Hausnummer"
name="street"
type="text"
value={street}
onChange={(e) => setStreet(e.target.value)}
placeholder="Musterstraße 1"
autoComplete="street-address"
required
wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0"
/>
<div className="flex flex-col sm:flex-row gap-4 w-full">
<FormField label="PLZ" name="zip" type="text" value={zip} onChange={(e) => setZip(e.target.value)} placeholder="10115" autoComplete="postal-code" required />
<FormField label="Ort" name="city" type="text" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Berlin" autoComplete="address-level2" required />
+11 -9
View File
@@ -18,7 +18,6 @@ export function AddToCartButton({
className,
productId = "todo-karten",
outOfStock = false,
lowStock = false,
variants = [],
}: {
label: string;
@@ -30,9 +29,6 @@ export function AddToCartButton({
/** Product-level — only meaningful when `variants` is empty, same split as
* AddToCartInlineButton. */
outOfStock?: boolean;
/** Product-level low-stock hint, same "only meaningful without variants"
* split as outOfStock. */
lowStock?: boolean;
/** Optional — same shape/semantics as AddToCartInlineButton's own
* `variants` prop; all three callers already fetch the full product
* server-side, so this is just threaded straight through. */
@@ -47,7 +43,6 @@ export function AddToCartButton({
useEffect(() => () => clearTimeout(timeoutRef.current), []);
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
const currentlyLowStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.lowStock ?? false) : lowStock;
function handleClick() {
if (currentlyOutOfStock) return;
@@ -81,7 +76,17 @@ export function AddToCartButton({
const displayLabel = currentlyOutOfStock ? "Ausverkauft" : label;
return (
<div className="flex flex-col gap-1">
// Low stock is deliberately NOT surfaced here as its own text line
// (it used to be) — that made this block's height vary card-to-card
// in every grid that renders this component, breaking equal-height
// card alignment (ProductSpotlight's CTA row, RelatedProducts' grid).
// The image-overlaid pill badge (ProductGrid.tsx/ProductSpotlight.tsx/
// RelatedProducts.tsx, position: absolute, doesn't participate in
// layout flow) is the one place this now shows, same as
// Ausverkauft/discount already do. The variant-select suffix below is
// unaffected — a native <select>'s own height doesn't vary with its
// option text.
<div className="flex flex-col gap-2">
{variants.length > 0 && (
<select
value={selectedVariant}
@@ -97,9 +102,6 @@ export function AddToCartButton({
))}
</select>
)}
{currentlyLowStock && !currentlyOutOfStock && (
<p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>
)}
<button
ref={buttonRef}
type="button"
+6 -9
View File
@@ -21,7 +21,6 @@ export function AddToCartInlineButton({
label = "In den Warenkorb",
className,
outOfStock = false,
lowStock = false,
variants = [],
}: {
id: string;
@@ -30,9 +29,6 @@ export function AddToCartInlineButton({
/** Product-level only meaningful when `variants` is empty. A varianted
* product's buyability is entirely per-variant instead (see below). */
outOfStock?: boolean;
/** Product-level low-stock hint, same "only meaningful without variants"
* split as outOfStock. */
lowStock?: boolean;
/** Optional products.variants (name + optional priceOverride + its own
* outOfStock). When non-empty, a variant must be picked (defaults to the
* first *in-stock* one, or just the first if all are out) before "add to
@@ -51,7 +47,6 @@ export function AddToCartInlineButton({
// Whichever is actually being offered right now — the selected variant's
// own flag if there are variants, otherwise the plain product-level one.
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
const currentlyLowStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.lowStock ?? false) : lowStock;
function handleClick() {
if (currentlyOutOfStock) return;
@@ -77,7 +72,12 @@ export function AddToCartInlineButton({
: "border-border hover:border-brand";
return (
<div className="flex flex-col gap-1 w-full">
// Low stock isn't shown as its own text line here (see
// AddToCartButton.tsx's identical comment on why) — the image-overlaid
// pill badge (ProductGrid.tsx/RelatedProducts.tsx, position: absolute,
// outside layout flow) is where this shows now, same as
// Ausverkauft/discount already do.
<div className="flex flex-col gap-2 w-full">
{variants.length > 0 && (
<select
value={selectedVariant}
@@ -93,9 +93,6 @@ export function AddToCartInlineButton({
))}
</select>
)}
{currentlyLowStock && !currentlyOutOfStock && (
<p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>
)}
<button
ref={buttonRef}
type="button"
+1 -1
View File
@@ -102,7 +102,7 @@ export async function ProductSpotlight() {
(matches Tools/Blog above/below), same as AddToCartButton's
own default styling/ring-offset, so no override is needed
here. */}
<AddToCartButton label="In den Warenkorb" productId={product.id} outOfStock={product.outOfStock} lowStock={product.lowStock} variants={product.variants} />
<AddToCartButton label="In den Warenkorb" productId={product.id} outOfStock={product.outOfStock} variants={product.variants} />
{product.href && (
<Link
href={product.href}
+18 -8
View File
@@ -4,24 +4,34 @@ import type { TaxBreakdownGroup } from "../lib/taxBreakdown";
// The actual amount of VAT included in a total — not just a disclosure
// that VAT is included (see cartTotals.ts's effectiveTaxRate() for the
// "which %" shown next to each line item elsewhere). One line per rate
// when a cart/order spans more than one; a single line otherwise.
// when a cart/order spans more than one; a single line otherwise. Each
// line is a flex row with a spacer (same "label, flex-1 spacer, value"
// shape as every other Zwischensumme/Versand/Gesamtsumme row in the
// sidebars this renders inside) so every € amount lands on the same
// right-hand edge — a plain "X%: Y €" text line left the amount starting
// wherever the rate's own digit count happened to end, visibly misaligned
// as soon as two different rates (e.g. "7%" vs "19%") were both present.
export function VatBreakdown({ groups }: { groups: TaxBreakdownGroup[] }) {
if (groups.length === 0) return null;
if (groups.length === 1) {
const [g] = groups;
return (
<p className="text-label text-text-muted">
enthält {g.rate}% MwSt.: {formatPrice(g.tax)}
</p>
<div className="flex items-center w-full">
<span className="text-label text-text-muted">enthält {g.rate}% MwSt.</span>
<span className="flex-1" />
<span className="text-label text-text-muted">{formatPrice(g.tax)}</span>
</div>
);
}
return (
<div className="flex flex-col gap-0.5">
<div className="flex flex-col gap-0.5 w-full">
<p className="text-label text-text-muted">enthält MwSt.:</p>
{groups.map((g) => (
<p key={g.rate} className="text-label text-text-muted pl-2">
{g.rate}%: {formatPrice(g.tax)}
</p>
<div key={g.rate} className="flex items-center w-full pl-2">
<span className="text-label text-text-muted">{g.rate}%</span>
<span className="flex-1" />
<span className="text-label text-text-muted">{formatPrice(g.tax)}</span>
</div>
))}
</div>
);
+3 -3
View File
@@ -14,10 +14,10 @@ export type CheckoutDraft = {
firstName: string;
lastName: string;
email: string;
deliveryMethod: "address" | "packstation";
// Rechnungsadresse is always a plain street address now — no
// deliveryMethod/packstationNumber/postNumber here, only on the
// shipping* override fields below (see CheckoutContent.tsx).
street: string;
packstationNumber: string;
postNumber: string;
zip: string;
city: string;
country: string;
+25
View File
@@ -44,6 +44,31 @@ async function fetchDiscountCode(code: string): Promise<PayloadDiscountCode | nu
return data.docs?.[0] ?? null;
}
// Whether it's worth showing the cart's manual "Rabattcode" input field at
// all — no point offering an open text field for a shopper to type into
// when there's nothing in Payload that could ever validate. Existence-only
// check (active: true), not the fuller validFrom/validUntil/minOrderValue
// window validateDiscountCode() does for an actual submitted code — this
// just gates whether the field renders, the real validation still happens
// at apply time regardless.
export async function hasActiveDiscountCode(): Promise<boolean> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[active][equals]": "true",
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/discount-codes?${params}`, {
headers: { "x-discount-service-secret": SERVICE_SECRET },
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`hasActiveDiscountCode: Payload returned ${res.status} ${res.statusText}`);
return false;
}
const data: { docs?: unknown[] } = await res.json();
return (data.docs?.length ?? 0) > 0;
}
export type DiscountValidation =
| { valid: true; doc: PayloadDiscountCode }
| { valid: false; reason: string };
+1 -1
View File
@@ -104,7 +104,7 @@ export async function ProductGrid() {
equal-height lesson). */}
<div className="flex-1" />
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} lowStock={product.lowStock} variants={product.variants} />
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} variants={product.variants} />
</div>
</RevealItem>
);
+14 -2
View File
@@ -26,6 +26,9 @@ export async function Pricing() {
if (!product) return null;
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
// Same "any vs. every" split as ProductGrid.tsx/ProductSpotlight.tsx.
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock;
return (
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-8">
@@ -41,10 +44,20 @@ export async function Pricing() {
sizes="(min-width: 1024px) 410px, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{discount !== null && (
{fullyOutOfStock ? (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
) : 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>
) : (
anyLowStock && (
<span className="absolute top-3 left-3 rounded-full bg-warning px-2.5 py-1 text-label font-bold text-text-on-dark">
Nur noch wenige verfügbar
</span>
)
)}
</div>
@@ -86,7 +99,6 @@ export async function Pricing() {
label="In den Warenkorb"
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
outOfStock={product.outOfStock}
lowStock={product.lowStock}
variants={product.variants}
/>
</div>
+1 -1
View File
@@ -114,7 +114,7 @@ export async function TodoKartenHero() {
</div>
{product && (
<AddToCartButton label="ToDo-Karten bestellen" outOfStock={product.outOfStock} lowStock={product.lowStock} variants={product.variants} />
<AddToCartButton label="ToDo-Karten bestellen" outOfStock={product.outOfStock} variants={product.variants} />
)}
</div>
</Reveal>