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

@@ -1,14 +1,17 @@
import { formatPrice, formatWeight } from "../lib/formatters";
import { useCurrency } from "./useCurrency";
import { useLanguage } from "./useLanguage";
import { useWeightUnit } from "./useWeightUnit";
export function useFormatters() {
const unit = useWeightUnit();
const { currency } = useCurrency();
const locale = useLanguage();
return {
weight: (grams: number | null) => formatWeight(grams, unit),
price: (cents: number | null) => formatPrice(cents, currency),
weight: (grams: number | null) => formatWeight(grams, unit, locale),
price: (cents: number | null) => formatPrice(cents, currency, locale),
unit,
currency,
locale,
};
}

View File

@@ -0,0 +1,12 @@
import { useSetting } from "./useSettings";
export const VALID_LANGUAGES = ["en", "de"] as const;
export type Language = (typeof VALID_LANGUAGES)[number];
export function useLanguage(): Language {
const { data } = useSetting("language");
if (data && VALID_LANGUAGES.includes(data as Language)) {
return data as Language;
}
return "en";
}

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 };
}