Honor Products.noShippingCost across cart, checkout, and product pages
- api/checkout/route.ts: authoritative shipping charge is 0 whenever every cart line opts out via noShippingCost, regardless of the free-shipping threshold. - Cart/checkout order summaries: the whole "Versand" line (cost, free- shipping note, delivery time) is hidden entirely rather than showing "Kostenlos" — that's a different state from hitting the threshold. - ProductSpotlight/Pricing/TodoKartenHero: "zzgl. Versand" and delivery- time hints drop for an exempted product's own page. - /versand + its shared modal: one clarifying sentence that digital products are exempt. - Widerrufsformular link: opens inline in a new tab (no forced download), arrow icon changed from a download glyph to a plain right arrow to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -198,12 +198,19 @@ export async function POST(request: Request) {
|
||||
});
|
||||
}
|
||||
const subtotal = roundMoney(items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0));
|
||||
// Products.noShippingCost — a cart made up entirely of items that opt
|
||||
// out of shipping costs (e.g. purely digital downloads) never gets
|
||||
// charged shipping at all, regardless of the free-shipping threshold.
|
||||
// A single item WITHOUT the flag still triggers normal shipping for the
|
||||
// whole order — this only exempts a product, never the whole cart just
|
||||
// because it contains an exempt item.
|
||||
const hasShippableItem = body.cart.some((line) => !productsBySlug.get(line.id)?.noShippingCost);
|
||||
|
||||
const shippingMethods = await getShippingMethods();
|
||||
const shippingMethod = shippingMethods.find((m) => m.id === body.shippingMethodId);
|
||||
if (!shippingMethod) return NextResponse.json({ ok: false, reason: "Versandart ist ungültig." }, { status: 400 });
|
||||
const freeShipping = shippingMethod.freeShippingThreshold != null && subtotal >= shippingMethod.freeShippingThreshold;
|
||||
const shippingCost = freeShipping ? 0 : shippingMethod.price;
|
||||
const shippingCost = !hasShippableItem || freeShipping ? 0 : shippingMethod.price;
|
||||
|
||||
const paymentMethods = await getPaymentMethods();
|
||||
const paymentMethod = paymentMethods.find((m) => m.id === body.paymentMethodId);
|
||||
|
||||
@@ -7,7 +7,7 @@ import Image from "next/image";
|
||||
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { useDiscount, applyDiscount, clearDiscount } from "../../lib/discount";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate, cartHasShippableItem } from "../../lib/cartTotals";
|
||||
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { formatPrice, discountPercent } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
@@ -77,7 +77,7 @@ export function CartContent({
|
||||
|
||||
const subtotal = computeSubtotal(items);
|
||||
const shipping =
|
||||
items.length === 0 || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
|
||||
items.length === 0 || !cartHasShippableItem(items) || (freeShippingThreshold !== null && subtotal >= freeShippingThreshold)
|
||||
? 0
|
||||
: shippingCost;
|
||||
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
|
||||
@@ -392,31 +392,40 @@ export function CartContent({
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && freeShippingThreshold !== null
|
||||
? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: "innerhalb Deutschlands"}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
{/* Hidden entirely (not just "Kostenlos") when nothing in
|
||||
the cart actually triggers shipping at all — that's a
|
||||
different state from hitting the free-shipping
|
||||
threshold, which is still a real promotional message
|
||||
worth showing. */}
|
||||
{cartHasShippableItem(items) && (
|
||||
<>
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && freeShippingThreshold !== null
|
||||
? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: "innerhalb Deutschlands"}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useCart, clearCart, mergeServerCartIntoLocal } from "../../lib/cart";
|
||||
import { useProducts } from "../../lib/products";
|
||||
import { useDiscount, clearDiscount } from "../../lib/discount";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
|
||||
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate, cartHasShippableItem } from "../../lib/cartTotals";
|
||||
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
|
||||
import { formatPrice } from "../../lib/format";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
@@ -372,7 +372,7 @@ export function CheckoutContent({
|
||||
selectedShipping?.freeShippingThreshold !== null &&
|
||||
selectedShipping?.freeShippingThreshold !== undefined &&
|
||||
subtotal >= selectedShipping.freeShippingThreshold;
|
||||
const shipping = items.length === 0 || freeShipping ? 0 : selectedShipping?.price ?? 0;
|
||||
const shipping = items.length === 0 || !cartHasShippableItem(items) || freeShipping ? 0 : selectedShipping?.price ?? 0;
|
||||
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
|
||||
const taxBreakdown = computeTaxBreakdown(
|
||||
items.map(({ entry, product }) => ({
|
||||
@@ -1337,33 +1337,39 @@ export function CheckoutContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{displayShipping === 0 ? "Kostenlos" : formatPrice(displayShipping)}
|
||||
</span>
|
||||
{/* Hidden entirely (not just "Kostenlos") when nothing in the
|
||||
cart actually triggers shipping at all — a different state
|
||||
from hitting the free-shipping threshold, which is still a
|
||||
real promotional message worth showing. */}
|
||||
{cartHasShippableItem(items) && (
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{displayShipping === 0 ? "Kostenlos" : formatPrice(displayShipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && selectedShipping?.freeShippingThreshold != null
|
||||
? `ab ${formatPrice(selectedShipping.freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: (selectedShipping?.description ?? "innerhalb Deutschlands")}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0 && selectedShipping?.freeShippingThreshold != null
|
||||
? `ab ${formatPrice(selectedShipping.freeShippingThreshold)} innerhalb Deutschlands`
|
||||
: (selectedShipping?.description ?? "innerhalb Deutschlands")}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
|
||||
@@ -86,10 +86,19 @@ export async function ProductSpotlight() {
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
{(() => {
|
||||
const priceHint = kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? null
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt.${product.noShippingCost ? "" : " zzgl. Versand"}`;
|
||||
return priceHint && <p className="text-label text-text-muted">{priceHint}</p>;
|
||||
})()}
|
||||
{!product.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Single product, no grid siblings to stay equal-height with
|
||||
(unlike ProductGrid.tsx/RelatedProducts.tsx), so this can be
|
||||
|
||||
@@ -10,6 +10,7 @@ const product = (overrides: Partial<RawProduct> = {}): RawProduct => ({
|
||||
active: true,
|
||||
image: null,
|
||||
taxRatePercent: null,
|
||||
noShippingCost: false,
|
||||
bundleItems: null,
|
||||
variants: null,
|
||||
trackInventory: false,
|
||||
|
||||
@@ -22,6 +22,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
lowStock: false,
|
||||
maxQty: null,
|
||||
taxRatePercent: null,
|
||||
noShippingCost: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -37,6 +37,16 @@ export function computeSubtotal(items: CartLine[]): number {
|
||||
return items.reduce((sum, { entry, product }) => sum + entry.qty * effectivePrice(entry, product), 0);
|
||||
}
|
||||
|
||||
// A cart only needs a shipping line at all if at least one item doesn't
|
||||
// opt out via Products.noShippingCost (e.g. a purely digital download) —
|
||||
// mirrors api/checkout/route.ts's own hasShippableItem check, which is
|
||||
// the actual charged amount; this is only the storefront's estimate/
|
||||
// display before that. A single non-exempt item still triggers normal
|
||||
// shipping for the whole cart, this never partially discounts it.
|
||||
export function cartHasShippableItem(items: CartLine[]): boolean {
|
||||
return items.some(({ product }) => !product.noShippingCost);
|
||||
}
|
||||
|
||||
export type CartTotals = {
|
||||
subtotal: number;
|
||||
/** compareAtPrice-based per-product savings — already excluded from
|
||||
|
||||
@@ -207,6 +207,13 @@ export type Product = {
|
||||
// storefront; the actual rate used for order totals is resolved and
|
||||
// snapshotted server-side at checkout (api/checkout/route.ts).
|
||||
taxRatePercent: number | null;
|
||||
// No shipping cost for this product at all (e.g. a digital download) —
|
||||
// never shows "zzgl. Versand" on its own product page, and doesn't count
|
||||
// toward "does this cart need a shipping line" (lib/cartTotals.ts's
|
||||
// cartHasShippableItem()). A cart with even one item that does NOT have
|
||||
// this set still gets charged/shown the normal shipping cost — this only
|
||||
// exempts the individual product, not the whole cart.
|
||||
noShippingCost: boolean;
|
||||
variants: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
|
||||
};
|
||||
|
||||
@@ -231,6 +238,7 @@ type PayloadProduct = {
|
||||
allowBackorder: boolean;
|
||||
lowStockThreshold: number | null;
|
||||
taxRatePercent: number | null;
|
||||
noShippingCost: boolean;
|
||||
variants:
|
||||
| {
|
||||
name: string;
|
||||
@@ -291,6 +299,7 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
|
||||
lowStock: isLowStock(product.trackInventory, product.stock, product.lowStockThreshold),
|
||||
maxQty: maxPurchasableQty(product.trackInventory, product.stock, product.allowBackorder),
|
||||
taxRatePercent: product.taxRatePercent ?? null,
|
||||
noShippingCost: product.noShippingCost,
|
||||
variants: (product.variants ?? []).map((v) => ({
|
||||
name: v.name,
|
||||
priceOverride: v.priceOverride,
|
||||
|
||||
@@ -24,6 +24,7 @@ export type RawProduct = {
|
||||
active: boolean;
|
||||
image: { url: string } | number | null;
|
||||
taxRatePercent: number | null;
|
||||
noShippingCost: boolean;
|
||||
bundleItems: { product: { id: number; name: string } | number; quantity: number }[] | null;
|
||||
variants: RawProductVariant[] | null;
|
||||
trackInventory: boolean;
|
||||
|
||||
@@ -97,10 +97,19 @@ export async function Pricing() {
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
{(() => {
|
||||
const priceHint = kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? null
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt.${product.noShippingCost ? "" : " zzgl. Versand"}`;
|
||||
return priceHint && <p className="text-label text-text-muted">{priceHint}</p>;
|
||||
})()}
|
||||
{!product.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Single product, no grid siblings to stay equal-height with —
|
||||
plain conditional line, same reasoning as ProductSpotlight.tsx. */}
|
||||
|
||||
@@ -124,12 +124,21 @@ export async function TodoKartenHero() {
|
||||
<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">{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}</p>
|
||||
{(() => {
|
||||
const priceHint = kleinunternehmer
|
||||
? product.noShippingCost
|
||||
? null
|
||||
: "zzgl. Versand"
|
||||
: `inkl. ${taxRate}% MwSt.${product.noShippingCost ? "" : " zzgl. Versand"}`;
|
||||
return priceHint && <p className="text-label text-text-muted">{priceHint}</p>;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
{!product?.noShippingCost && (
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
)}
|
||||
{/* Single product, no grid siblings to stay equal-height with —
|
||||
plain conditional line, same reasoning as ProductSpotlight.tsx/Pricing.tsx. */}
|
||||
{anyLowStock && <p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>}
|
||||
|
||||
@@ -77,6 +77,10 @@ export function VersandSections({
|
||||
wir kostenlos.
|
||||
</p>
|
||||
<p>Alle angegebenen Preise verstehen sich inklusive der gesetzlichen Mehrwertsteuer.</p>
|
||||
<p>
|
||||
Rein digitale Produkte (z.B. Downloads) verursachen keine Versandkosten und sind von den
|
||||
oben genannten Beträgen ausgenommen.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="liefergebiet" title="Liefergebiet" withAnchor={withAnchors}>
|
||||
|
||||
@@ -113,11 +113,11 @@ export default async function WiderrufPage() {
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="size-5 shrink-0 text-text-muted group-hover:text-brand group-hover:translate-y-0.5 transition-all"
|
||||
className="size-5 shrink-0 text-text-muted group-hover:text-brand group-hover:translate-x-0.5 transition-all"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 4v13m0 0-5-5m5 5 5-5M5 21h14" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M5 12h14m0 0-6-6m6 6-6 6" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user