From a3f843ad15966f3546d77054cb7eda72c884a305 Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 29 Jul 2026 22:48:33 +0000 Subject: [PATCH] Honor Products.noShippingCost across cart, checkout, and product pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/api/checkout/route.ts | 9 ++- app/cart/components/CartContent.tsx | 63 +++++++++++--------- app/checkout/components/CheckoutContent.tsx | 62 ++++++++++--------- app/components/ProductSpotlight.tsx | 17 ++++-- app/lib/__tests__/bundleContents.test.ts | 1 + app/lib/__tests__/cartTotals.test.ts | 1 + app/lib/cartTotals.ts | 10 ++++ app/lib/payload.ts | 9 +++ app/lib/productsServer.ts | 1 + app/todo-cards/components/Pricing.tsx | 17 ++++-- app/todo-cards/components/TodoKartenHero.tsx | 17 ++++-- app/versand/components/VersandSections.tsx | 4 ++ app/widerruf/page.tsx | 4 +- 13 files changed, 145 insertions(+), 70 deletions(-) diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts index e689437..a2b4e19 100644 --- a/app/api/checkout/route.ts +++ b/app/api/checkout/route.ts @@ -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); diff --git a/app/cart/components/CartContent.tsx b/app/cart/components/CartContent.tsx index a82ea0b..a5dac29 100644 --- a/app/cart/components/CartContent.tsx +++ b/app/cart/components/CartContent.tsx @@ -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({ )}
-
- - Versand - - - - - {shipping === 0 ? "Kostenlos" : formatPrice(shipping)} - -
-

- {shipping === 0 && freeShippingThreshold !== null - ? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands` - : "innerhalb Deutschlands"} -

-

- Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage -

+ {/* 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) && ( + <> +
+ + Versand + + + + + {shipping === 0 ? "Kostenlos" : formatPrice(shipping)} + +
+

+ {shipping === 0 && freeShippingThreshold !== null + ? `ab ${formatPrice(freeShippingThreshold)} innerhalb Deutschlands` + : "innerhalb Deutschlands"} +

+

+ Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage +

+ + )}
diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx index 96549c8..bd022b8 100644 --- a/app/checkout/components/CheckoutContent.tsx +++ b/app/checkout/components/CheckoutContent.tsx @@ -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({
)} -
-
- - Versand - - - - - {displayShipping === 0 ? "Kostenlos" : formatPrice(displayShipping)} - + {/* 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) && ( +
+
+ + Versand + + + + + {displayShipping === 0 ? "Kostenlos" : formatPrice(displayShipping)} + +
+

+ {shipping === 0 && selectedShipping?.freeShippingThreshold != null + ? `ab ${formatPrice(selectedShipping.freeShippingThreshold)} innerhalb Deutschlands` + : (selectedShipping?.description ?? "innerhalb Deutschlands")} +

+

+ Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage +

-

- {shipping === 0 && selectedShipping?.freeShippingThreshold != null - ? `ab ${formatPrice(selectedShipping.freeShippingThreshold)} innerhalb Deutschlands` - : (selectedShipping?.description ?? "innerhalb Deutschlands")} -

-

- Lieferzeit {shippingSettings.totalDays.min}–{shippingSettings.totalDays.max} Werktage -

-
+ )}
diff --git a/app/components/ProductSpotlight.tsx b/app/components/ProductSpotlight.tsx index e5a058c..2bd509b 100644 --- a/app/components/ProductSpotlight.tsx +++ b/app/components/ProductSpotlight.tsx @@ -86,10 +86,19 @@ export async function ProductSpotlight() { )}

{formatPrice(product.price)}

-

{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}

-

- Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands -

+ {(() => { + const priceHint = kleinunternehmer + ? product.noShippingCost + ? null + : "zzgl. Versand" + : `inkl. ${taxRate}% MwSt.${product.noShippingCost ? "" : " zzgl. Versand"}`; + return priceHint &&

{priceHint}

; + })()} + {!product.noShippingCost && ( +

+ Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands +

+ )}
{/* Single product, no grid siblings to stay equal-height with (unlike ProductGrid.tsx/RelatedProducts.tsx), so this can be diff --git a/app/lib/__tests__/bundleContents.test.ts b/app/lib/__tests__/bundleContents.test.ts index 48dd115..11c0073 100644 --- a/app/lib/__tests__/bundleContents.test.ts +++ b/app/lib/__tests__/bundleContents.test.ts @@ -10,6 +10,7 @@ const product = (overrides: Partial = {}): RawProduct => ({ active: true, image: null, taxRatePercent: null, + noShippingCost: false, bundleItems: null, variants: null, trackInventory: false, diff --git a/app/lib/__tests__/cartTotals.test.ts b/app/lib/__tests__/cartTotals.test.ts index 7c00c09..7ffbd7e 100644 --- a/app/lib/__tests__/cartTotals.test.ts +++ b/app/lib/__tests__/cartTotals.test.ts @@ -22,6 +22,7 @@ const product = (overrides: Partial = {}): Product => ({ lowStock: false, maxQty: null, taxRatePercent: null, + noShippingCost: false, ...overrides, }); diff --git a/app/lib/cartTotals.ts b/app/lib/cartTotals.ts index 8028884..c003428 100644 --- a/app/lib/cartTotals.ts +++ b/app/lib/cartTotals.ts @@ -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 diff --git a/app/lib/payload.ts b/app/lib/payload.ts index 271ded9..5f4e301 100644 --- a/app/lib/payload.ts +++ b/app/lib/payload.ts @@ -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, diff --git a/app/lib/productsServer.ts b/app/lib/productsServer.ts index 9b27cb2..cc9a8e8 100644 --- a/app/lib/productsServer.ts +++ b/app/lib/productsServer.ts @@ -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; diff --git a/app/todo-cards/components/Pricing.tsx b/app/todo-cards/components/Pricing.tsx index 3761f6d..c2a7ff3 100644 --- a/app/todo-cards/components/Pricing.tsx +++ b/app/todo-cards/components/Pricing.tsx @@ -97,10 +97,19 @@ export async function Pricing() { )}

{formatPrice(product.price)}

-

{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}

-

- Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands -

+ {(() => { + const priceHint = kleinunternehmer + ? product.noShippingCost + ? null + : "zzgl. Versand" + : `inkl. ${taxRate}% MwSt.${product.noShippingCost ? "" : " zzgl. Versand"}`; + return priceHint &&

{priceHint}

; + })()} + {!product.noShippingCost && ( +

+ Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands +

+ )} {/* Single product, no grid siblings to stay equal-height with — plain conditional line, same reasoning as ProductSpotlight.tsx. */} diff --git a/app/todo-cards/components/TodoKartenHero.tsx b/app/todo-cards/components/TodoKartenHero.tsx index 34d1da6..d49532c 100644 --- a/app/todo-cards/components/TodoKartenHero.tsx +++ b/app/todo-cards/components/TodoKartenHero.tsx @@ -124,12 +124,21 @@ export async function TodoKartenHero() {

{formatPrice(product.compareAtPrice!)}

)}

{formatPrice(product.price)}

-

{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}

+ {(() => { + const priceHint = kleinunternehmer + ? product.noShippingCost + ? null + : "zzgl. Versand" + : `inkl. ${taxRate}% MwSt.${product.noShippingCost ? "" : " zzgl. Versand"}`; + return priceHint &&

{priceHint}

; + })()} )} -

- Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands -

+ {!product?.noShippingCost && ( +

+ Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands +

+ )} {/* Single product, no grid siblings to stay equal-height with — plain conditional line, same reasoning as ProductSpotlight.tsx/Pricing.tsx. */} {anyLowStock &&

Nur noch wenige verfügbar

} diff --git a/app/versand/components/VersandSections.tsx b/app/versand/components/VersandSections.tsx index 5231ad5..389adc6 100644 --- a/app/versand/components/VersandSections.tsx +++ b/app/versand/components/VersandSections.tsx @@ -77,6 +77,10 @@ export function VersandSections({ wir kostenlos.

Alle angegebenen Preise verstehen sich inklusive der gesetzlichen Mehrwertsteuer.

+

+ Rein digitale Produkte (z.B. Downloads) verursachen keine Versandkosten und sind von den + oben genannten Beträgen ausgenommen. +

diff --git a/app/widerruf/page.tsx b/app/widerruf/page.tsx index e08e6be..e830ae3 100644 --- a/app/widerruf/page.tsx +++ b/app/widerruf/page.tsx @@ -113,11 +113,11 @@ export default async function WiderrufPage() {