+ {/* 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
-
-
-
-
- {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) && (
+
+ 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() {
)}
- 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.
+