UnitPrice
A 330ml bottle priced at €4.20 also shows "€12.73 / 1l" on the shelf. A 500g bag of coffee shows "€19.98 / kg". These per-unit labels are how customers compare prices across pack sizes, and in many EU markets they are required by law on grocery and household goods. The shape connectors return for them is UnitPrice: a Money value, the quantity the variant actually contains, and the reference quantity the price is normalized to.
import type { UnitPrice } from '@laioutr-core/core-types/common';
interface UnitPrice {
price: Money; // cost for the reference quantity
quantity: Measurement; // the variant's actual content (e.g. 330ml)
reference: Measurement; // the unit shown to the customer (e.g. 100ml or 1l)
}
UnitPrice is a flat record, not a discriminated union. Variants would not pay their way: the rendering convention ("price slash unit") is the same whether the product is a drink, a detergent, or a bag of flour.
Fields
price
A Money value carrying the cost for the reference quantity, not the variant's actual content. A 330ml bottle that retails at €4.20 with a per-100ml unit price has price: { amount: 127, currency: 'EUR' } (€1.27 per 100ml), regardless of how many ml the variant contains.
The currency must match the rest of the entity's pricing. Mixing a USD unitPrice.price with a EUR prices.price is structurally possible but invalid; the storefront has one currency per market.
quantity
A Measurement ({ value: number, unit: string }) describing how much of the product the variant actually contains. For a 330ml bottle of soda, quantity: { value: 330, unit: 'ml' }. For a 500g bag of coffee, { value: 500, unit: 'g' }. This is informational; the renderer does not divide by it.
reference
The unit the price is normalized to. This is the value shown after the slash on the shelf label, the one customers compare across products. Conventions vary by category:
| Product category | Common reference | Example label |
|---|---|---|
| Beverages | 100ml or 1l | €1.27 / 100ml |
| Solid food | 100g or 1kg | €19.98 / kg |
| Detergents | 1l or 1kg | €2.45 / l |
| Paper goods | 1sheet or 100sheet | €0.04 / sheet |
| Wine, spirits | 750ml (the bottle), 1l | €18.66 / 750ml |
Stick to the reference that customers expect for the category. The unit field accepts the ECMA-402 sanctioned identifiers plus the Laioutr-known units ('ml', 'l', 'g', 'kg', 'sheet', 'item', etc.). Custom strings work but may not localize correctly.
Rendering UnitPrice
A UnitPrice value is just data. The renderer pairs the global $money and $measurement formatters:
<script setup lang="ts">
import type { UnitPrice } from '@laioutr-core/core-types/common';
const props = defineProps<{ unitPrice: UnitPrice }>();
</script>
<template>
<span>{{ $money(unitPrice.price) }} / {{ $measurement(unitPrice.reference) }}</span>
<!-- Renders 'CHF 1.27 / 100ml', '€19.98 / kg', etc. -->
</template>
Both formatters read the active locale, so the label localizes alongside the rest of the page. The UI Kit's product tiles (ProductTileBasic, ProductTileBasicReverse) and cart line items (CartListItem) emit this label automatically when their unitPrice prop is set.
For connector authors
When the source backend has a unit price (Shopify exposes it on ProductVariant.unitPrice and ProductVariant.unitPriceMeasurement; Adobe Commerce surfaces it on attribute-driven products; Hygraph and Storyblok let editors enter it manually), map the three fields directly:
import type { UnitPrice } from '@laioutr-core/core-types/common';
import { Money } from '@screeny05/ts-money';
const toUnitPrice = (raw: ShopifyUnitPrice): UnitPrice => ({
price: Money.fromDecimal(raw.amount, raw.currencyCode),
quantity: { value: raw.measuredVariant.value, unit: raw.measuredVariant.unit },
reference: { value: raw.measuredReference.value, unit: raw.measuredReference.unit },
});
If the backend gives you only the variant's pack size and a per-100g convention, compute the price yourself rather than dropping the field entirely:
const variantWeightG = 500; // 500g pack
const variantPriceCents = 1499; // €14.99
const referenceG = 100; // per 100g
const unitPrice: UnitPrice = {
price: { amount: Math.round(variantPriceCents * referenceG / variantWeightG), currency: 'EUR' },
quantity: { value: variantWeightG, unit: 'g' },
reference: { value: referenceG, unit: 'g' },
};
// price.amount === 300, renders as '€3.00 / 100g'
When the backend has no unit-price data at all, leave the field undefined. The storefront skips the label cleanly; do not fabricate one from the headline price alone, since that produces a label customers cannot compare against competitor products in the same category.
Related
Money: the type behindUnitPrice.price, including the$moneyhelper and zero-decimal currency handling.Measurement: the type behindUnitPrice.quantityandUnitPrice.reference, including the$measurementhelper and the supported unit codes.- Locale-aware formatting: the full set of UI Kit formatters (
$money,$measurement,$timespan,$duration). - Product and ProductVariant: the entities whose
pricescomponents carryUnitPricevalues. - schema.org/UnitPriceSpecification and the Shopify ProductVariant.unitPrice docs: the upstream conventions Laioutr follows.
Money
The flat record returned anywhere a connector or component schema yields a price. Two fields, locale-aware rendering through the $money formatter.
Measurement
The flat record carrying a numeric value and a unit identifier. Locale-aware rendering through the $measurement formatter, with metric/imperial selection driven by the active language.