a3f843ad15
- 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>
74 lines
3.5 KiB
TypeScript
74 lines
3.5 KiB
TypeScript
import { discountPercent } from "./format";
|
|
import type { Product } from "./payload";
|
|
|
|
// Previously duplicated independently in CartContent.tsx, CheckoutContent.tsx,
|
|
// and BestellbestaetigungContent.tsx — pulled into one place now that adding
|
|
// discount-code math to all three at once would otherwise mean hand-editing
|
|
// 3 near-identical blocks (and risking them drifting apart).
|
|
|
|
export type CartLine = { entry: { qty: number; variant?: string }; product: Product };
|
|
export type DiscountLike = { type: "percent" | "fixed"; value: number };
|
|
|
|
// A selected variant's priceOverride wins over the base product price —
|
|
// null/undefined priceOverride (or no variant selected at all) falls back
|
|
// to it. The one place cart/checkout math needs to know about variants at
|
|
// all; every other total below builds on this instead of `product.price`
|
|
// directly.
|
|
export function effectivePrice(entry: { variant?: string }, product: Product): number {
|
|
if (!entry.variant) return product.price;
|
|
const variant = product.variants.find((v) => v.name === entry.variant);
|
|
return variant?.priceOverride ?? product.price;
|
|
}
|
|
|
|
// A product's own taxRatePercent override wins over the tenant's default
|
|
// rate — mirrors api/checkout/route.ts's server-side snapshot logic
|
|
// (`product.taxRatePercent ?? defaultTaxRate`), kept in sync deliberately
|
|
// since this is only ever used for display, never for the actual charged
|
|
// amount.
|
|
export function effectiveTaxRate(product: Product, defaultRate: number): number {
|
|
return product.taxRatePercent ?? defaultRate;
|
|
}
|
|
|
|
// Split out from computeCartTotals() below because callers need a subtotal
|
|
// figure *before* they can decide a shipping cost (e.g. checking it against
|
|
// a free-shipping threshold) — which computeCartTotals itself takes as an
|
|
// input, not something it can decide on its own.
|
|
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
|
|
* `subtotal` (which uses `price`, not `compareAtPrice`), this is a
|
|
* separate display line only. */
|
|
totalSavings: number;
|
|
discountAmount: number;
|
|
total: number;
|
|
};
|
|
|
|
export function computeCartTotals(items: CartLine[], shippingCost: number, discount?: DiscountLike | null): CartTotals {
|
|
const subtotal = computeSubtotal(items);
|
|
const totalSavings = items.reduce((sum, { entry, product }) => {
|
|
const productDiscountPct = discountPercent(product.price, product.compareAtPrice);
|
|
return productDiscountPct !== null ? sum + entry.qty * (product.compareAtPrice! - product.price) : sum;
|
|
}, 0);
|
|
const discountAmount = !discount
|
|
? 0
|
|
: discount.type === "percent"
|
|
? (subtotal * discount.value) / 100
|
|
: Math.min(discount.value, subtotal);
|
|
const total = Math.max(0, subtotal - discountAmount) + shippingCost;
|
|
return { subtotal, totalSavings, discountAmount, total };
|
|
}
|