feat(i18n): locale-aware formatters and useLanguage hook

- Create useLanguage() hook following useCurrency/useWeightUnit pattern
- Update formatPrice() to use Intl.NumberFormat for locale-aware currency display
- Update formatWeight() to use Intl.NumberFormat for locale-aware number formatting
- Update formatDualPrice() to pass locale through
- Update useFormatters() to pass locale to all formatters
- Add formatter tests for en/de locales (15 tests passing)

Phase 34, Plan 03
This commit is contained in:
2026-04-13 18:20:23 +02:00
parent 672b17fd13
commit f759dd0fde
4 changed files with 127 additions and 22 deletions

View File

@@ -7,41 +7,50 @@ const GRAMS_PER_KG = 1000;
export function formatWeight(
grams: number | null | undefined,
unit: WeightUnit = "g",
locale = "en",
): string {
if (grams == null) return "--";
let value: number;
let fractionDigits: number;
switch (unit) {
case "g":
return `${Math.round(grams)}g`;
value = Math.round(grams);
fractionDigits = 0;
break;
case "oz":
return `${(grams / GRAMS_PER_OZ).toFixed(1)} oz`;
value = grams / GRAMS_PER_OZ;
fractionDigits = 1;
break;
case "lb":
return `${(grams / GRAMS_PER_LB).toFixed(2)} lb`;
value = grams / GRAMS_PER_LB;
fractionDigits = 2;
break;
case "kg":
return `${(grams / GRAMS_PER_KG).toFixed(2)} kg`;
value = grams / GRAMS_PER_KG;
fractionDigits = 2;
break;
}
const formatted = new Intl.NumberFormat(locale, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}).format(value);
return unit === "g" ? `${formatted}g` : `${formatted} ${unit}`;
}
export type Currency = "USD" | "EUR" | "GBP" | "JPY" | "CAD" | "AUD";
const CURRENCY_SYMBOLS: Record<Currency, string> = {
USD: "$",
EUR: "€",
GBP: "£",
JPY: "¥",
CAD: "CA$",
AUD: "A$",
};
export function formatPrice(
cents: number | null | undefined,
currency: Currency = "USD",
locale = "en",
): string {
if (cents == null) return "--";
const symbol = CURRENCY_SYMBOLS[currency];
if (currency === "JPY") {
return `${symbol}${Math.round(cents / 100)}`;
}
return `${symbol}${(cents / 100).toFixed(2)}`;
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: currency === "JPY" ? 0 : 2,
maximumFractionDigits: currency === "JPY" ? 0 : 2,
}).format(cents / 100);
}
export interface DualPriceOptions {
@@ -49,6 +58,7 @@ export interface DualPriceOptions {
sourceCurrency: Currency;
targetCurrency: Currency;
convertedCents: number;
locale?: string;
}
/**
@@ -59,7 +69,8 @@ export function formatDualPrice(options: DualPriceOptions): {
source: string;
converted: string;
} {
const source = formatPrice(options.sourceCents, options.sourceCurrency);
const converted = `~${formatPrice(options.convertedCents, options.targetCurrency)}`;
const locale = options.locale ?? "en";
const source = formatPrice(options.sourceCents, options.sourceCurrency, locale);
const converted = `~${formatPrice(options.convertedCents, options.targetCurrency, locale)}`;
return { source, converted };
}