{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "lib", "title": "Lumo commerce model and money utilities", "description": "The canonical commerce data model (Product, Variant, Money, CartLine, Address) and minor-unit-aware money helpers that every Lumo block builds on. Map your backend onto these types once.", "files": [ { "path": "packages/utils/src/types.ts", "content": "/**\r\n * Lumo UI canonical commerce data model.\r\n *\r\n * Components are headless-agnostic: they consume these normalized shapes via\r\n * props. Map your backend (Shopify, Medusa, commercetools, custom) onto these\r\n * types once, and every Lumo block works against it.\r\n *\r\n * Money is stored in integer minor units (for example cents) to avoid\r\n * floating-point rounding errors. Use `formatMoney` to render it.\r\n */\r\n\r\n/** ISO 4217 currency code, e.g. 'USD', 'EUR', 'JPY'. */\r\nexport type CurrencyCode = string\r\n\r\n/**\r\n * A monetary amount in integer minor units plus its currency.\r\n *\r\n * @example { amount: 1999, currency: 'USD' } // represents $19.99\r\n * @example { amount: 500, currency: 'JPY' } // represents ¥500 (JPY has 0 minor units)\r\n */\r\nexport interface Money {\r\n /** Integer amount in the currency's minor units (e.g. cents). */\r\n amount: number\r\n currency: CurrencyCode\r\n}\r\n\r\nexport interface Image {\r\n url: string\r\n alt?: string\r\n width?: number\r\n height?: number\r\n}\r\n\r\n/** A configurable axis of a product, e.g. { name: 'Size', values: ['S','M','L'] }. */\r\nexport interface ProductOption {\r\n name: string\r\n values: string[]\r\n}\r\n\r\n/** A concrete option choice on a variant, e.g. { name: 'Size', value: 'M' }. */\r\nexport interface SelectedOption {\r\n name: string\r\n value: string\r\n}\r\n\r\n/** A purchasable variant of a product (a specific SKU / option combination). */\r\nexport interface Variant {\r\n id: string\r\n sku?: string\r\n title?: string\r\n price: Money\r\n /** Original price before discount; drives sale and compare-at display. */\r\n compareAtPrice?: Money\r\n selectedOptions: SelectedOption[]\r\n available: boolean\r\n inventoryQuantity?: number\r\n image?: Image\r\n}\r\n\r\nexport interface ProductRating {\r\n /** Average rating value, typically 0 to 5. */\r\n value: number\r\n /** Number of reviews behind the rating. */\r\n count: number\r\n}\r\n\r\nexport interface Product {\r\n id: string\r\n /** URL-friendly slug. */\r\n handle: string\r\n title: string\r\n description?: string\r\n images: Image[]\r\n options: ProductOption[]\r\n variants: Variant[]\r\n /** Id of the variant selected by default; falls back to the first available. */\r\n defaultVariantId?: string\r\n rating?: ProductRating\r\n /** Display badges, e.g. 'sale', 'new', 'limited', or custom labels. */\r\n badges?: string[]\r\n tags?: string[]\r\n vendor?: string\r\n}\r\n\r\n/** A line in the cart: a variant, its parent product summary, and a quantity. */\r\nexport interface CartLine {\r\n id: string\r\n variant: Variant\r\n product: Pick\r\n quantity: number\r\n lineTotal: Money\r\n}\r\n\r\nexport interface Cart {\r\n id?: string\r\n lines: CartLine[]\r\n currency: CurrencyCode\r\n subtotal: Money\r\n /**\r\n * Grand total. Optional because tax, shipping, and discount logic is owned\r\n * by your backend, not by Lumo components.\r\n */\r\n total?: Money\r\n}\r\n\r\nexport interface Address {\r\n firstName: string\r\n lastName: string\r\n line1: string\r\n line2?: string\r\n city: string\r\n /** State or province. */\r\n region?: string\r\n postalCode: string\r\n /** ISO 3166-1 alpha-2 country code, e.g. 'US', 'FR'. */\r\n country: string\r\n phone?: string\r\n}\r\n", "type": "registry:lib", "target": "lib/lumo/types.ts" }, { "path": "packages/utils/src/format-money.ts", "content": "import type { Money } from './types'\r\n\r\n/**\r\n * Number of minor-unit digits for a currency, derived from Intl.\r\n * USD -> 2, JPY -> 0, BHD -> 3. Falls back to 2 if the runtime lacks the data.\r\n */\r\nexport function getCurrencyFractionDigits(currency: string): number {\r\n try {\r\n const resolved = new Intl.NumberFormat('en-US', {\r\n style: 'currency',\r\n currency,\r\n }).resolvedOptions()\r\n return resolved.maximumFractionDigits ?? 2\r\n } catch {\r\n return 2\r\n }\r\n}\r\n\r\n/** Convert a Money amount from minor units to a major-unit decimal number. */\r\nexport function toMajorUnits(money: Money): number {\r\n const digits = getCurrencyFractionDigits(money.currency)\r\n return money.amount / 10 ** digits\r\n}\r\n\r\n/** Convert a major-unit decimal number to integer minor units. */\r\nexport function toMinorUnits(amount: number, currency: string): number {\r\n const digits = getCurrencyFractionDigits(currency)\r\n return Math.round(amount * 10 ** digits)\r\n}\r\n\r\n/**\r\n * Format a Money value as a localized currency string.\r\n *\r\n * @example formatMoney({ amount: 1999, currency: 'USD' }) // \"$19.99\"\r\n * @example formatMoney({ amount: 500, currency: 'JPY' }, { locale: 'ja-JP' }) // \"¥500\"\r\n */\r\nexport function formatMoney(\r\n money: Money,\r\n options: { locale?: string } & Omit<\r\n Intl.NumberFormatOptions,\r\n 'style' | 'currency'\r\n > = {}\r\n): string {\r\n const { locale, ...numberFormatOptions } = options\r\n return new Intl.NumberFormat(locale, {\r\n style: 'currency',\r\n currency: money.currency,\r\n ...numberFormatOptions,\r\n }).format(toMajorUnits(money))\r\n}\r\n\r\n/** Create a zero-value Money in the given currency. */\r\nexport function zeroMoney(currency: string): Money {\r\n return { amount: 0, currency }\r\n}\r\n\r\n/**\r\n * Add Money values. All inputs must share a currency, otherwise it throws,\r\n * because silently mixing currencies is a correctness bug.\r\n */\r\nexport function addMoney(...values: Money[]): Money {\r\n if (values.length === 0) {\r\n throw new Error('addMoney requires at least one value')\r\n }\r\n const [first] = values\r\n const { currency } = first\r\n let amount = 0\r\n for (const value of values) {\r\n if (value.currency !== currency) {\r\n throw new Error(\r\n `Cannot add Money of different currencies: ${currency} and ${value.currency}`\r\n )\r\n }\r\n amount += value.amount\r\n }\r\n return { amount, currency }\r\n}\r\n\r\n/** Multiply a Money value by an integer quantity (e.g. a line total). */\r\nexport function multiplyMoney(money: Money, quantity: number): Money {\r\n return { amount: Math.round(money.amount * quantity), currency: money.currency }\r\n}\r\n\r\n/** Discount percentage between an original and current price, 0 to 100. */\r\nexport function discountPercent(original: Money, current: Money): number {\r\n if (original.currency !== current.currency) {\r\n throw new Error(\r\n `Cannot compare Money of different currencies: ${original.currency} and ${current.currency}`\r\n )\r\n }\r\n if (original.amount <= 0 || current.amount >= original.amount) {\r\n return 0\r\n }\r\n return Math.round(((original.amount - current.amount) / original.amount) * 100)\r\n}\r\n", "type": "registry:lib", "target": "lib/lumo/money.ts" } ], "type": "registry:lib" }