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:
@@ -1,14 +1,17 @@
|
|||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||||
import { useCurrency } from "./useCurrency";
|
import { useCurrency } from "./useCurrency";
|
||||||
|
import { useLanguage } from "./useLanguage";
|
||||||
import { useWeightUnit } from "./useWeightUnit";
|
import { useWeightUnit } from "./useWeightUnit";
|
||||||
|
|
||||||
export function useFormatters() {
|
export function useFormatters() {
|
||||||
const unit = useWeightUnit();
|
const unit = useWeightUnit();
|
||||||
const { currency } = useCurrency();
|
const { currency } = useCurrency();
|
||||||
|
const locale = useLanguage();
|
||||||
return {
|
return {
|
||||||
weight: (grams: number | null) => formatWeight(grams, unit),
|
weight: (grams: number | null) => formatWeight(grams, unit, locale),
|
||||||
price: (cents: number | null) => formatPrice(cents, currency),
|
price: (cents: number | null) => formatPrice(cents, currency, locale),
|
||||||
unit,
|
unit,
|
||||||
currency,
|
currency,
|
||||||
|
locale,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/client/hooks/useLanguage.ts
Normal file
12
src/client/hooks/useLanguage.ts
Normal 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";
|
||||||
|
}
|
||||||
@@ -7,41 +7,50 @@ const GRAMS_PER_KG = 1000;
|
|||||||
export function formatWeight(
|
export function formatWeight(
|
||||||
grams: number | null | undefined,
|
grams: number | null | undefined,
|
||||||
unit: WeightUnit = "g",
|
unit: WeightUnit = "g",
|
||||||
|
locale = "en",
|
||||||
): string {
|
): string {
|
||||||
if (grams == null) return "--";
|
if (grams == null) return "--";
|
||||||
|
let value: number;
|
||||||
|
let fractionDigits: number;
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
case "g":
|
case "g":
|
||||||
return `${Math.round(grams)}g`;
|
value = Math.round(grams);
|
||||||
|
fractionDigits = 0;
|
||||||
|
break;
|
||||||
case "oz":
|
case "oz":
|
||||||
return `${(grams / GRAMS_PER_OZ).toFixed(1)} oz`;
|
value = grams / GRAMS_PER_OZ;
|
||||||
|
fractionDigits = 1;
|
||||||
|
break;
|
||||||
case "lb":
|
case "lb":
|
||||||
return `${(grams / GRAMS_PER_LB).toFixed(2)} lb`;
|
value = grams / GRAMS_PER_LB;
|
||||||
|
fractionDigits = 2;
|
||||||
|
break;
|
||||||
case "kg":
|
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";
|
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(
|
export function formatPrice(
|
||||||
cents: number | null | undefined,
|
cents: number | null | undefined,
|
||||||
currency: Currency = "USD",
|
currency: Currency = "USD",
|
||||||
|
locale = "en",
|
||||||
): string {
|
): string {
|
||||||
if (cents == null) return "--";
|
if (cents == null) return "--";
|
||||||
const symbol = CURRENCY_SYMBOLS[currency];
|
return new Intl.NumberFormat(locale, {
|
||||||
if (currency === "JPY") {
|
style: "currency",
|
||||||
return `${symbol}${Math.round(cents / 100)}`;
|
currency,
|
||||||
}
|
minimumFractionDigits: currency === "JPY" ? 0 : 2,
|
||||||
return `${symbol}${(cents / 100).toFixed(2)}`;
|
maximumFractionDigits: currency === "JPY" ? 0 : 2,
|
||||||
|
}).format(cents / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DualPriceOptions {
|
export interface DualPriceOptions {
|
||||||
@@ -49,6 +58,7 @@ export interface DualPriceOptions {
|
|||||||
sourceCurrency: Currency;
|
sourceCurrency: Currency;
|
||||||
targetCurrency: Currency;
|
targetCurrency: Currency;
|
||||||
convertedCents: number;
|
convertedCents: number;
|
||||||
|
locale?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,7 +69,8 @@ export function formatDualPrice(options: DualPriceOptions): {
|
|||||||
source: string;
|
source: string;
|
||||||
converted: string;
|
converted: string;
|
||||||
} {
|
} {
|
||||||
const source = formatPrice(options.sourceCents, options.sourceCurrency);
|
const locale = options.locale ?? "en";
|
||||||
const converted = `~${formatPrice(options.convertedCents, options.targetCurrency)}`;
|
const source = formatPrice(options.sourceCents, options.sourceCurrency, locale);
|
||||||
|
const converted = `~${formatPrice(options.convertedCents, options.targetCurrency, locale)}`;
|
||||||
return { source, converted };
|
return { source, converted };
|
||||||
}
|
}
|
||||||
|
|||||||
79
tests/formatters.test.ts
Normal file
79
tests/formatters.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user