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

79
tests/formatters.test.ts Normal file
View File

@@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test";
import { formatPrice, formatWeight } from "../src/client/lib/formatters";
describe("formatPrice", () => {
test("returns -- for null", () => {
expect(formatPrice(null)).toBe("--");
});
test("returns -- for undefined", () => {
expect(formatPrice(undefined)).toBe("--");
});
test("formats USD with en locale", () => {
const result = formatPrice(12345, "USD", "en");
expect(result).toContain("123.45");
expect(result).toContain("$");
});
test("formats EUR with de locale", () => {
const result = formatPrice(12345, "EUR", "de");
// German uses comma for decimal
expect(result).toContain("123,45");
});
test("formats JPY with no decimals", () => {
const result = formatPrice(10000, "JPY", "en");
expect(result).toContain("100");
});
test("formats large amounts with en locale", () => {
const result = formatPrice(123456789, "USD", "en");
expect(result).toContain("$");
});
test("defaults to en locale when no locale provided", () => {
const result = formatPrice(12345, "USD");
expect(result).toContain("$");
});
});
describe("formatWeight", () => {
test("returns -- for null", () => {
expect(formatWeight(null)).toBe("--");
});
test("returns -- for undefined", () => {
expect(formatWeight(undefined)).toBe("--");
});
test("formats grams with en locale", () => {
const result = formatWeight(500, "g", "en");
expect(result).toBe("500g");
});
test("formats large grams with en locale thousands separator", () => {
const result = formatWeight(1234, "g", "en");
expect(result).toContain("g");
});
test("formats ounces", () => {
const result = formatWeight(100, "oz", "en");
expect(result).toContain("oz");
});
test("formats kilograms", () => {
const result = formatWeight(1500, "kg", "en");
expect(result).toContain("kg");
});
test("formats pounds", () => {
const result = formatWeight(1000, "lb", "en");
expect(result).toContain("lb");
});
test("defaults to en locale when no locale provided", () => {
const result = formatWeight(500, "g");
expect(result).toBe("500g");
});
});