import type { Product } from 'brainerce'; import { getProductPriceInfo, getVariantPrice } from 'brainerce'; /** * Region display pricing (the FX overlay). * * When a product read was made with `regionId` AND the region's currency * differs from the store currency, the backend attaches additive * `displayPrice`, `displaySalePrice`, `displayPriceMin`, `displayPriceMax` and * `displayCurrency` fields from the daily FX snapshot. They are DISPLAY ONLY: * `basePrice` / `salePrice` stay in the store currency and the cart still * charges in it. * * ⛔ NEVER read `displayPrice` on its own. It is absent whenever the region * currency equals the store currency, which is every single-region store, so a * bare read blanks the price on almost every storefront. Always fall back, and * that is the entire reason these helpers exist. * * ⛔ EVERY PRODUCT PRICE ON SCREEN MUST GO THROUGH HERE. Convert one surface * and not another and the shopper sees EUR on the card they clicked and USD on * the page it opened, which is worse than never converting at all. This lives * in `core/lib/` rather than beside one component precisely so a redesign * cannot leave half the surfaces behind. * * ⛔ NOT FOR THE CART, CHECKOUT, ORDER TOTALS, OR THE TAX ESTIMATE. Those are * charged amounts in the store currency. Using a display amount there would * show a total that does not match what the card is billed. * * ⛔ NOT FOR ANALYTICS. `view_item` / `add_to_cart` must report one currency * across the whole store or the revenue numbers are meaningless. Keep sending * the store-currency figure. */ /** The additive FX fields, as they appear on both Product and ProductVariant. */ interface DisplayPriceFields { displayPrice?: string | null; displaySalePrice?: string | null; displayCurrency?: string | null; } export interface DisplayPrice { /** * Base (pre-sale) amount. Feeds ``. * * Always a number, never undefined: `getProductPriceInfo` returns 0 for a * product with no usable price rather than nothing, so the render path has * no undefined branch to guard. */ price: number; /** Sale amount, or null when not on sale. Feeds ``. */ salePrice: number | null; /** * Currency `price` / `salePrice` are in. * * ⛔ ALWAYS pass this to `` / `formatPrice`. It * defaults to the STORE currency, so a converted number rendered without it * comes out as a euro amount wearing a dollar sign, which is worse than an * unconverted price because it looks right. */ currency: string | undefined; } /** * Prefer the region-converted amounts on `source`; otherwise return `fallback` * in the store currency. The primitive the rest of this file is built from. */ export function resolveDisplayPrice( source: DisplayPriceFields | null | undefined, fallback: { price: number; salePrice: number | null }, fallbackCurrency: string | undefined ): DisplayPrice { if (source?.displayPrice != null && source.displayCurrency) { const sale = source.displaySalePrice != null ? parseFloat(source.displaySalePrice) : null; return { // `displayPrice` is the BASE price converted, so it belongs in the base // slot, never the sale slot. price: parseFloat(source.displayPrice), salePrice: sale != null && !Number.isNaN(sale) ? sale : null, currency: source.displayCurrency, }; } return { ...fallback, currency: fallbackCurrency }; } /** * A product's own price for a card, a hero, or a recommendation tile. * * The same-currency fallback maps the SDK's shape onto ``'s: * `getProductPriceInfo().price` is the EFFECTIVE charged amount (the sale * price when on sale), and `originalPrice` is the base. */ export function pickDisplayPrice( product: Product, fallbackCurrency: string | undefined ): DisplayPrice { const { price: effective, originalPrice, isOnSale } = getProductPriceInfo(product); return resolveDisplayPrice( product, { price: originalPrice, salePrice: isOnSale ? effective : null }, fallbackCurrency ); } /** * A variable product's "from X to Y" range. * * ⛔ `displayPriceMin` / `displayPriceMax` are their own fields. A range built * from `priceMin` / `priceMax` (or by walking `variants`) stays in the store * currency while the single-price card beside it converts, which is the same * split-currency bug one level down. Returns null when there is no range to * show, which is what a product with no variants and no bounds gives you. */ export function pickDisplayPriceRange( product: Product, fallbackCurrency: string | undefined ): { min: number; max: number; currency: string | undefined } | null { if (product.displayPriceMin && product.displayPriceMax && product.displayCurrency) { const min = parseFloat(product.displayPriceMin); const max = parseFloat(product.displayPriceMax); if (!Number.isNaN(min) && !Number.isNaN(max)) { return { min, max, currency: product.displayCurrency }; } } let min: number; let max: number; if (product.priceMin && product.priceMax) { min = parseFloat(product.priceMin); max = parseFloat(product.priceMax); } else { const variants = product.variants ?? []; if (variants.length === 0) return null; const prices = variants.map((v) => getVariantPrice(v, product.basePrice)); min = Math.min(...prices); max = Math.max(...prices); } if (Number.isNaN(min) || Number.isNaN(max)) return null; return { min, max, currency: fallbackCurrency }; }