From f759dd0fde756d288f6cda2515ae89096b1065d0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 13 Apr 2026 18:20:23 +0200 Subject: [PATCH] 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 --- src/client/hooks/useFormatters.ts | 7 ++- src/client/hooks/useLanguage.ts | 12 +++++ src/client/lib/formatters.ts | 51 ++++++++++++-------- tests/formatters.test.ts | 79 +++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 22 deletions(-) create mode 100644 src/client/hooks/useLanguage.ts create mode 100644 tests/formatters.test.ts diff --git a/src/client/hooks/useFormatters.ts b/src/client/hooks/useFormatters.ts index 5049065..77767c9 100644 --- a/src/client/hooks/useFormatters.ts +++ b/src/client/hooks/useFormatters.ts @@ -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, }; } diff --git a/src/client/hooks/useLanguage.ts b/src/client/hooks/useLanguage.ts new file mode 100644 index 0000000..4bd2cb2 --- /dev/null +++ b/src/client/hooks/useLanguage.ts @@ -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"; +} diff --git a/src/client/lib/formatters.ts b/src/client/lib/formatters.ts index fdcef5c..f09c501 100644 --- a/src/client/lib/formatters.ts +++ b/src/client/lib/formatters.ts @@ -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 = { - 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 }; } diff --git a/tests/formatters.test.ts b/tests/formatters.test.ts new file mode 100644 index 0000000..d4f581f --- /dev/null +++ b/tests/formatters.test.ts @@ -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"); + }); +});