feat(i18n): localize Spanish translations and currency formatting (#160)

## Why

### Problem
The app had two categories of localization gaps:
1. Many UI strings were not translated — Spanish users saw raw English
text because keys were missing from `lang/es.json`.
2. All currency and number formatting was hardcoded to `'en-US'` (or
`undefined`), so amounts always formatted in English style regardless of
the user's locale setting.

## What

### Changes
- **`lang/es.json`** — Added 22 missing Spanish translation keys
covering onboarding, automation rules, balance history, dashboard, and
more.
- **`utils/currency.ts`** — Added `locale` parameter (default `'en-US'`)
to `formatCurrency`.
- **`utils/chart.tsx`** — Added `locale` parameter to
`formatCurrencyWithCode`; replaced `.toLocaleString()` calls with
locale-aware versions.
- **`hooks/use-dashboard-data.ts`** — Added `locale` param to
`formatMonth` and `deriveAccountMetrics`; wired `useLocale()` inside
`useDashboardData`.
- **`pages/dashboard.tsx`** — Added `useLocale()` and passed `locale` to
`deriveAccountMetrics`.
- **`components/charts/sankey-chart.tsx`** — Added `locale` param to
`formatAmount` and threaded it through `OtherCategoriesBreakdown`.
- **`components/charts/mom-chart.tsx`** — Replaced hardcoded `'en-US'`
in YAxis tickFormatter with `useLocale()`.
- **`components/budgets/budget-list-card.tsx`** — Passed `locale` to all
`formatCurrency` calls.
- **`components/budgets/budget-spending-chart.tsx`** — Passed `locale`
to all `formatCurrency` calls in `CustomTooltip`.
- **`components/ui/amount-display.tsx`** — Added `useLocale()` and
passed to `Intl.NumberFormat`.
- **`components/ui/amount-input.tsx`** — Added `useLocale()` and passed
to `Intl.NumberFormat` used for display formatting.
- **`components/accounts/balances-modal.tsx`** — Added `useLocale()` for
both the balance formatter and `formatDate`.
- **`components/transactions/edit-transaction-dialog.tsx`** — Replaced
hardcoded `'en-US'` with `useLocale()`.
-
**`components/accounts/import-balances/import-balance-step-mapping.tsx`**
— Replaced `'en-US'` with `useLocale()` in preview formatting.
- **`components/transactions/import-step-mapping.tsx`** — Replaced
`'en-US'` with `useLocale()` in preview formatting.
- **`components/onboarding/step-create-account.tsx`** — Fixed garbled
Spanish caused by 3-part JSX string concatenation; replaced with single
translation key.
- **`components/automation-rules/create-automation-rule-dialog.tsx`** —
Fixed multiline description key mismatch with `es.json`.

## Verification

### Tests
- TypeScript check (`npx tsc --noEmit`) passes with no new errors
introduced by these changes.
- `lang/es.json` validated as well-formed JSON.

### Manual
- With locale set to `es`, all previously untranslated strings now
render in Spanish.
- Currency amounts format according to the user's locale (e.g.,
`1.234,56 €` in `es` vs `$1,234.56` in `en-US`).
This commit is contained in:
Víctor Falcón 2026-02-28 18:46:18 +00:00 committed by GitHub
parent 77b225d747
commit 2b9fd2384a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 198 additions and 123 deletions

View File

@ -61,6 +61,7 @@
"Account name": "Nombre de cuenta",
"Accounts": "Cuentas",
"Accounts Created": "Cuentas Creadas",
"Accounts need to be mapped before syncing can begin.": "Las cuentas deben mapearse antes de que pueda comenzar la sincronización.",
"Actions": "Acciones",
"Add Another Account": "Agregar Otra Cuenta",
"Add Condition": "Agregar Condición",
@ -97,6 +98,7 @@
"Any errors or omissions in content or any loss or damage incurred from using content": "Cualquier error u omisión en el contenido o cualquier pérdida o daño incurrido por usar el contenido",
"Any interruption or cessation of transmission to or from the service": "Cualquier interrupción o cese de transmisión hacia o desde el servicio",
"Any unauthorized access to or use of our servers and/or personal information": "Cualquier acceso no autorizado o uso de nuestros servidores y/o información personal",
"An unexpected error occurred during sync.": "Se produjo un error inesperado durante la sincronización.",
"Appearance": "Apariencia",
"Appearance settings": "Configuración de apariencia",
"Are you overspending? Know exactly where you stand.": "¿Estás gastando de más? Conoce exactamente dónde estás parado.",
@ -125,6 +127,7 @@
"Bi-weekly": "Quincenal",
"Bank": "Banco",
"Bank accounts": "Cuentas bancarias",
"Bank Connections": "Conexiones Bancarias",
"Bank logo preview": "Vista previa del logo del banco",
"Bank name": "Nombre del banco",
"Beautiful charts and graphs help you understand your spending patterns.": "Gráficos hermosos te ayudan a entender tus patrones de gasto.",
@ -173,6 +176,8 @@
"Company Name:": "Nombre de la Empresa:",
"Conditions": "Condiciones",
"Conditions joined by:": "Condiciones unidas por:",
"Connect Bank": "Conectar Banco",
"Connections": "Conexiones",
"Confirm": "Confirmar",
"Confirm Password": "Confirmar Contraseña",
"Confirm password": "Confirmar contraseña",
@ -184,6 +189,7 @@
"Continue Setup": "Continuar Configuración",
"Continue to Import": "Continuar con Importación",
"Create \"": "Crear \"",
"Create": "Crear",
"Create Account": "Crear Cuenta",
"Create Automation Rule": "Crear Regla de Automatización",
"Create Budget": "Crear Presupuesto",
@ -222,6 +228,7 @@
"Dark": "Oscuro",
"Dashboard": "Panel",
"Data Accuracy:": "Precisión de Datos:",
"Daily": "Diario",
"Daily spending and transactions": "Gastos diarios y transacciones",
"Data Imported": "Datos Importados",
"Data is Encrypted": "Los Datos Están Encriptados",
@ -249,6 +256,7 @@
"Disable 2FA": "Desactivar 2FA",
"Disable privacy mode": "Desactivar modo privado",
"Disabled": "Desactivado",
"Disconnect": "Desconectar",
"Discord for 80% off": "Discord con 80% de descuento",
"Don't have an account?": "¿No tienes una cuenta?",
"Download as CSV or Excel format": "Descargar en formato CSV o Excel",
@ -312,6 +320,7 @@
"Export": "Exportar",
"Export Data": "Exportar Datos",
"Export from any bank": "Exporta desde cualquier banco",
"Expires": "Vence",
"Failed to create transaction": "Error al crear la transacción",
"Failed to decrypt message. Please check your password and try again.": "Error al descifrar el mensaje. Por favor verifica tu contraseña e inténtalo de nuevo.",
"Failed to update transaction": "Error al actualizar la transacción",
@ -392,6 +401,7 @@
"Language": "Idioma",
"lime": "lima",
"Last updated:": "Última actualización:",
"Last synced": "Última sincronización",
"Latest transactions in this account": "Últimas transacciones en esta cuenta",
"Let's Get Started": "Comencemos",
"Lifetime": "De por vida",
@ -421,6 +431,7 @@
"Manage Plan": "Gestionar Plan",
"Manage Subscription": "Gestionar Suscripción",
"Manage your bank accounts": "Gestiona tus cuentas bancarias",
"Manage your connected bank accounts for automatic transaction syncing.": "Gestiona tus cuentas bancarias conectadas para sincronización automática de transacciones.",
"Manage your profile and account settings": "Gestiona tu perfil y configuración de cuenta",
"Manage your subscription, update payment methods, or view invoices through the Stripe billing portal.": "Gestiona tu suscripción, actualiza métodos de pago o consulta facturas a través del portal de facturación de Stripe.",
"Manage your transaction automation rules": "Gestiona tus reglas de automatización de transacciones",
@ -468,12 +479,14 @@
"No expenses this period": "Sin gastos en este período",
"No income this period": "Sin ingresos en este período",
"No labels found.": "No se encontraron etiquetas.",
"No bank connections yet. Connect a bank to automatically sync your transactions.": "Aún no hay conexiones bancarias. Conecta un banco para sincronizar automáticamente tus transacciones.",
"No results found": "No se encontraron resultados",
"No spending data this month": "Sin datos de gasto este mes",
"No transactions found.": "No se encontraron transacciones.",
"No tracking": "Sin seguimiento",
"None": "Ninguno",
"None (Remove)": "Ninguno (Eliminar)",
"Never": "Nunca",
"Notes": "Notas",
"Nothing in these Terms excludes or limits our liability for death or personal injury caused by negligence, fraud, or any liability that cannot be excluded or limited under applicable law.": "Nada en estos Términos excluye o limita nuestra responsabilidad por muerte o lesiones personales causadas por negligencia, fraude, o cualquier responsabilidad que no pueda ser excluida o limitada bajo la ley aplicable.",
"Notify us immediately of any unauthorized access or security breach": "Notifícanos inmediatamente de cualquier acceso no autorizado o violación de seguridad",
@ -639,7 +652,12 @@
"Supported formats": "Formatos soportados",
"Supports CSV, XLS, and XLSX files": "Soporta archivos CSV, XLS y XLSX",
"Sync now": "Sincronizar ahora",
"Sync Now": "Sincronizar Ahora",
"Switch to daily view": "Cambiar a vista diaria",
"Switch to monthly view": "Cambiar a vista mensual",
"Sync your encrypted financial data across multiple devices via cloud storage": "Sincroniza tus datos financieros encriptados en múltiples dispositivos a través de almacenamiento en la nube",
"Syncing balances\u2026": "Sincronizando balances\u2026",
"Syncing transactions and balances\u2026": "Sincronizando transacciones y balances\u2026",
"System": "Sistema",
"Tap": "Toca",
"Tap the": "Toca el botón",
@ -890,6 +908,7 @@
"Drop your CSV or Excel file here, or click to browse": "Arrastra tu archivo CSV o Excel aquí, o haz clic para buscar",
"Import transactions from CSV or Excel files": "Importar transacciones desde archivos CSV o Excel",
"Importing Transactions": "Importando Transacciones",
"Map Accounts": "Mapear Cuentas",
"Map Columns": "Mapear Columnas",
"Match your file columns to transaction fields": "Asocia las columnas de tu archivo con los campos de transacción",
"No bank found.": "No se encontró ningún banco.",
@ -1046,5 +1065,8 @@
"Reactivate Your Subscription": "Reactiva Tu Suscripción",
"Your data and settings will be preserved, so you can pick up right where you left off.": "Tus datos y configuración se conservarán, así que puedes retomar donde lo dejaste.",
"If you have any questions or just want to chat, simply reply to this email. I read and respond to every message personally.": "Si tienes alguna pregunta o simplemente quieres charlar, responde a este correo. Leo y respondo personalmente cada mensaje.",
"Thanks again for being part of this journey!": "¡Gracias de nuevo por ser parte de este viaje!"
"Thanks again for being part of this journey!": "¡Gracias de nuevo por ser parte de este viaje!",
"Need help? Join our Discord": "\u00bfNecesitas ayuda? \u00danete a nuestro Discord",
"Retry": "Reintentar",
"Update Credentials": "Actualizar Credenciales"
}

View File

@ -29,6 +29,7 @@ import {
import { useLocale } from '@/hooks/use-locale';
import { useIsMobile } from '@/hooks/use-mobile';
import { Account, supportsInvestedAmount } from '@/types/account';
import { formatCurrency } from '@/utils/currency';
import { formatDayFromDate, formatMonthFromYearMonth } from '@/utils/date';
import { __ } from '@/utils/i18n';
import { format, subDays, subMonths } from 'date-fns';
@ -106,7 +107,7 @@ function InvestmentTooltipContent({
{__('Gain/loss')}
</span>
<span
className={`whitespace-nowrap font-mono font-medium tabular-nums ${gain >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
className={`font-mono font-medium whitespace-nowrap tabular-nums ${gain >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
>
{gain >= 0 ? '+' : ''}
{valueFormatter(gain)}
@ -178,12 +179,7 @@ function formatChartCurrency(
currencyCode: string,
locale: string,
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value / 100);
return formatCurrency(value, currencyCode, locale, 0, 0);
}
function calculateTrend(

View File

@ -33,8 +33,10 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useLocale } from '@/hooks/use-locale';
import type { Account, AccountBalance } from '@/types/account';
import { supportsInvestedAmount } from '@/types/account';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { Pencil, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
@ -60,6 +62,7 @@ export function BalancesModal({
onOpenChange,
onBalanceChange,
}: BalancesModalProps) {
const locale = useLocale();
const [balances, setBalances] = useState<AccountBalance[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
@ -80,10 +83,8 @@ export function BalancesModal({
useState<AccountBalance | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const formatter = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: account.currency_code,
});
const formatBalance = (valueInCents: number) =>
formatCurrency(valueInCents, account.currency_code, locale);
const showInvestedAmount = supportsInvestedAmount(account);
@ -217,7 +218,7 @@ export function BalancesModal({
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString(undefined, {
return date.toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
@ -302,17 +303,16 @@ export function BalancesModal({
)}
</TableCell>
<TableCell className="text-right font-mono">
{formatter.format(
balance.balance / 100,
{formatBalance(
balance.balance,
)}
</TableCell>
{showInvestedAmount && (
<TableCell className="text-right font-mono text-muted-foreground">
{balance.invested_amount !==
null
? formatter.format(
balance.invested_amount /
100,
? formatBalance(
balance.invested_amount,
)
: '—'}
</TableCell>

View File

@ -8,6 +8,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useLocale } from '@/hooks/use-locale';
import { parseAmount, parseDate } from '@/lib/file-parser';
import type {
BalanceColumnMapping,
@ -45,11 +46,15 @@ export function ImportBalanceStepMapping({
onBack,
}: ImportBalanceStepMappingProps) {
const isValid = columnMapping.balance_date && columnMapping.balance;
const locale = useLocale();
const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
});
const formatRawAmount = (value: number) =>
new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
})
.format(value)
.replace(/\s/g, '\u202F');
const previewBalances = parsedData.slice(0, 3).map((row) => {
const date = columnMapping.balance_date
@ -69,22 +74,20 @@ export function ImportBalanceStepMapping({
);
investedAmount =
invested !== null
? currencyFormatter.format(invested)
? formatRawAmount(invested)
: 'Invalid amount';
}
return {
date: date
? date.toLocaleDateString('en-US', {
? date.toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
: 'Invalid date',
balance:
balance !== null
? currencyFormatter.format(balance)
: 'Invalid amount',
balance !== null ? formatRawAmount(balance) : 'Invalid amount',
investedAmount,
};
});

View File

@ -9,6 +9,7 @@ import {
} from '@/components/ui/table';
import { useLocale } from '@/hooks/use-locale';
import { type ParsedBalance } from '@/types/balance-import';
import { formatCurrency } from '@/utils/currency';
import { formatDateMedium } from '@/utils/date';
import { __ } from '@/utils/i18n';
@ -32,12 +33,8 @@ export function ImportBalanceStepPreview({
const locale = useLocale();
const total = balances.length;
const formatBalance = (balance: number): string => {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
}).format(balance / 100);
};
const formatBalance = (valueInCents: number): string =>
formatCurrency(valueInCents, currencyCode, locale);
const hasInvestedData =
showInvestedAmount && balances.some((b) => b.invested_amount !== null);

View File

@ -126,7 +126,7 @@ export function CreateAutomationRuleDialog({
<DialogTitle>{__('Create Automation Rule')}</DialogTitle>
<DialogDescription>
{__(
'Create a rule to automatically categorize transactions.',
'Create a rule to automatically categorize transactions and add labels.',
)}
</DialogDescription>
</DialogHeader>
@ -194,7 +194,7 @@ export function CreateAutomationRuleDialog({
disabled={isSubmitting}
data-testid="submit-automation-rule"
>
{isSubmitting ? 'Creating...' : 'Create'}
{isSubmitting ? __('Creating...') : __('Create')}
</Button>
</div>
</form>

View File

@ -102,9 +102,17 @@ export function BudgetListCard({ budget, currencyCode }: Props) {
{__('Spent')}
</span>
<span className={statusColor}>
{formatCurrency(stats.totalSpent, currencyCode)}{' '}
{formatCurrency(
stats.totalSpent,
currencyCode,
locale,
)}{' '}
{__('of')}{' '}
{formatCurrency(stats.totalAllocated, currencyCode)}
{formatCurrency(
stats.totalAllocated,
currencyCode,
locale,
)}
</span>
</div>
<Progress
@ -117,7 +125,11 @@ export function BudgetListCard({ budget, currencyCode }: Props) {
{__('Remaining')}
</span>
<span className={statusColor}>
{formatCurrency(stats.remaining, currencyCode)}
{formatCurrency(
stats.remaining,
currencyCode,
locale,
)}
</span>
</div>
</div>

View File

@ -77,7 +77,7 @@ function CustomTooltip({
{__('Allocated:')}
</span>
<span className="font-medium">
{formatCurrency(allocated, currencyCode)}
{formatCurrency(allocated, currencyCode, locale)}
</span>
</div>
<div className="flex items-center justify-between gap-8">
@ -85,7 +85,7 @@ function CustomTooltip({
{__('Spent:')}
</span>
<span className="font-medium">
{formatCurrency(spent, currencyCode)}
{formatCurrency(spent, currencyCode, locale)}
</span>
</div>
{hasPreviousPeriod && data.prevSpent !== undefined && (
@ -94,7 +94,11 @@ function CustomTooltip({
{__('Last period:')}
</span>
<span className="font-medium text-muted-foreground">
{formatCurrency(data.prevSpent, currencyCode)}
{formatCurrency(
data.prevSpent,
currencyCode,
locale,
)}
</span>
</div>
)}
@ -106,7 +110,11 @@ function CustomTooltip({
{percentage}% /
</span>
<span className="font-semibold">
{formatCurrency(available, currencyCode)}
{formatCurrency(
available,
currencyCode,
locale,
)}
</span>
</div>
</div>

View File

@ -1,5 +1,6 @@
import { AmountDisplay } from '@/components/ui/amount-display';
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
import { useLocale } from '@/hooks/use-locale';
import { ChangeDataPoint } from '@/lib/chart-calculations';
import { cn } from '@/lib/utils';
import { __ } from '@/utils/i18n';
@ -84,6 +85,7 @@ export function MoMChart({
}: MoMChartProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const minChartWidth = data.length * minBarWidth;
const locale = useLocale();
useEffect(() => {
if (scrollContainerRef.current) {
@ -120,7 +122,7 @@ export function MoMChart({
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => {
return new Intl.NumberFormat('en-US', {
return new Intl.NumberFormat(locale, {
notation: 'compact',
compactDisplay: 'short',
}).format(value / 100);

View File

@ -5,6 +5,7 @@ import {
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { SankeyCategory, SankeyData } from '@/hooks/use-cashflow-data';
import { useLocale } from '@/hooks/use-locale';
import {
calculatePercentage,
GroupedCategory,
@ -12,6 +13,7 @@ import {
} from '@/lib/sankey-utils';
import { cn } from '@/lib/utils';
import { Category } from '@/types/category';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { useMemo, useState } from 'react';
@ -49,20 +51,12 @@ const NODE_WIDTH = 8;
const NODE_PADDING = 6;
const MIN_NODE_HEIGHT = 20;
function formatAmount(amountInCents: number, currency: string): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amountInCents / 100);
}
interface OtherCategoriesBreakdownProps {
categories: SankeyCategory[];
total: number;
currency: string;
grandTotal: number;
locale: string;
}
function OtherCategoriesBreakdown({
@ -70,6 +64,7 @@ function OtherCategoriesBreakdown({
total,
currency,
grandTotal,
locale,
}: OtherCategoriesBreakdownProps) {
return (
<div className="w-64">
@ -111,7 +106,13 @@ function OtherCategoriesBreakdown({
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="font-medium">
{formatAmount(item.amount, currency)}
{formatCurrency(
item.amount,
currency,
locale,
0,
0,
)}
</span>
<span className="text-muted-foreground">
{percentage.toFixed(1)}%
@ -126,7 +127,7 @@ function OtherCategoriesBreakdown({
<div className="flex items-center justify-between text-sm font-medium">
<span>{__('Total')}</span>
<span>{formatAmount(total, currency)}</span>
<span>{formatCurrency(total, currency, locale, 0, 0)}</span>
</div>
</div>
</div>
@ -142,6 +143,7 @@ export function SankeyChart({
}: SankeyChartProps) {
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
const [hoveredLink, setHoveredLink] = useState<string | null>(null);
const locale = useLocale();
const { nodes, links, isEmpty, otherGroups } = useMemo(() => {
const {
@ -505,7 +507,13 @@ export function SankeyChart({
dominantBaseline="middle"
className="fill-muted-foreground text-[9px]"
>
{formatAmount(node.value, currency)}
{formatCurrency(
node.value,
currency,
locale,
0,
0,
)}
</text>
</g>
);
@ -520,7 +528,7 @@ export function SankeyChart({
<Popover key={node.id}>
<PopoverTrigger
asChild
aria-label={`View ${otherGroup.categories.length} grouped categories totaling ${formatAmount(otherGroup.total, currency)}`}
aria-label={`View ${otherGroup.categories.length} grouped categories totaling ${formatCurrency(otherGroup.total, currency, locale, 0, 0)}`}
>
{nodeContent}
</PopoverTrigger>
@ -536,6 +544,7 @@ export function SankeyChart({
total={otherGroup.total}
currency={currency}
grandTotal={grandTotal}
locale={locale}
/>
</PopoverContent>
</Popover>

View File

@ -40,7 +40,7 @@ export function StepComplete() {
<p className="mb-8 max-w-lg text-lg text-balance text-muted-foreground">
{__(
'Your accounts are ready and your data is securely encrypted.\n Welcome to Whisper Money!',
'Your accounts are ready and your data is securely encrypted. Welcome to Whisper Money!',
)}
</p>

View File

@ -277,9 +277,9 @@ export function StepCreateAccount({
{isFirstAccount && (
<div className="rounded-lg border border-blue-100 bg-blue-50 p-3 text-sm dark:border-blue-900/50 dark:bg-blue-900/20">
<p className="text-center">
{__('Your first account must be a')}{' '}
<strong>{__('Checking')}</strong>{' '}
{__('account.')}
{__(
'Your first account must be a checking account.',
)}
</p>
</div>
)}

View File

@ -722,10 +722,12 @@ export function EditTransactionDialog({
) : (
<div className="text-sm font-medium">
{transaction &&
new Intl.NumberFormat('en-US', {
new Intl.NumberFormat(locale, {
style: 'currency',
currency: transaction.currency_code,
}).format(transaction.amount / 100)}
})
.format(transaction.amount / 100)
.replace(/\s/g, '\u202F')}
</div>
)}
</div>

View File

@ -8,6 +8,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useLocale } from '@/hooks/use-locale';
import { parseAmount, parseDate } from '@/lib/file-parser';
import {
DateFormat,
@ -50,6 +51,7 @@ export function ImportStepMapping({
: columnMapping.description
? [columnMapping.description]
: [];
const locale = useLocale();
const isValid =
columnMapping.transaction_date &&
@ -114,7 +116,7 @@ export function ImportStepMapping({
return {
date: date
? date.toLocaleDateString('en-US', {
? date.toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
@ -123,10 +125,12 @@ export function ImportStepMapping({
description: description || 'No description',
amount:
amount !== null
? new Intl.NumberFormat('en-US', {
? new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
}).format(amount)
})
.format(amount)
.replace(/\s/g, '\u202F')
: 'Invalid amount',
};
});

View File

@ -1,6 +1,7 @@
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { useLocale } from '@/hooks/use-locale';
import { cn } from '@/lib/utils';
import { formatCurrency } from '@/utils/currency';
import { useEffect, useMemo, useState } from 'react';
interface AmountDisplayProps {
@ -75,13 +76,8 @@ export function AmountDisplay({
}, [amountInCents, shouldHideAmount]);
const formatted = useMemo(() => {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
minimumFractionDigits,
maximumFractionDigits,
}).format(amount);
}, [amount, currencyCode, minimumFractionDigits, maximumFractionDigits, locale]);
return formatCurrency(amount * 100, currencyCode, locale, minimumFractionDigits, maximumFractionDigits);
}, [locale, amount, currencyCode, minimumFractionDigits, maximumFractionDigits]);
const getBackgroundClass = (shouldHideAmount: boolean) => {
if (!highlightPositive && !shouldHideAmount) return '';

View File

@ -1,6 +1,7 @@
import * as React from 'react';
import { Input } from '@/components/ui/input';
import { useLocale } from '@/hooks/use-locale';
import { cn } from '@/lib/utils';
interface AmountInputProps {
@ -14,19 +15,27 @@ interface AmountInputProps {
className?: string;
}
const getCurrencySymbol = (currencyCode: string): string => {
const symbols: Record<string, string> = {
USD: '$',
EUR: '€',
GBP: '£',
JPY: '¥',
};
return symbols[currencyCode] || currencyCode;
const getCurrencyInfo = (
currencyCode: string,
locale: string,
): { symbol: string; position: 'prefix' | 'suffix' } => {
const parts = new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
}).formatToParts(1);
const symbolPart = parts.find((p) => p.type === 'currency');
const symbol = symbolPart?.value ?? currencyCode;
const symbolIndex = parts.findIndex((p) => p.type === 'currency');
const literalIndex = parts.findIndex((p) => p.type === 'integer');
const position = symbolIndex < literalIndex ? 'prefix' : 'suffix';
return { symbol, position };
};
const formatCurrency = (value: number): string => {
const formatCurrency = (value: number, locale: string): string => {
const amount = value / 100;
return new Intl.NumberFormat(undefined, {
return new Intl.NumberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
@ -158,6 +167,7 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
},
ref,
) => {
const locale = useLocale();
const [displayValue, setDisplayValue] = React.useState<string>('');
const [isFocused, setIsFocused] = React.useState<boolean>(false);
@ -166,7 +176,7 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
if (value === 0) {
setDisplayValue('');
} else {
setDisplayValue(formatCurrency(value));
setDisplayValue(formatCurrency(value, locale));
}
}
}, [value, isFocused]);
@ -205,13 +215,15 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
}
};
const currencySymbol = getCurrencySymbol(currencyCode);
const { symbol: currencySymbol, position: symbolPosition } = getCurrencyInfo(currencyCode, locale);
return (
<div className="relative">
<span className="-translate-y-1/2 absolute top-1/2 left-3 text-muted-foreground text-sm">
{currencySymbol}
</span>
{symbolPosition === 'prefix' && (
<span className="-translate-y-1/2 absolute top-1/2 left-3 text-muted-foreground text-sm">
{currencySymbol}
</span>
)}
<Input
ref={ref}
id={id}
@ -225,8 +237,17 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
placeholder={placeholder}
disabled={disabled}
required={required}
className={cn(["bg-background pl-9", className])}
className={cn([
'bg-background',
symbolPosition === 'prefix' ? 'pl-9' : 'pr-9',
className,
])}
/>
{symbolPosition === 'suffix' && (
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground text-sm">
{currencySymbol}
</span>
)}
</div>
);
},

View File

@ -1,7 +1,9 @@
import * as React from 'react';
import * as RechartsPrimitive from 'recharts';
import { useLocale } from '@/hooks/use-locale';
import { cn } from '@/lib/utils';
import { formatCurrency } from '@/utils/currency';
const THEMES = { light: '', dark: '.dark' } as const;
@ -133,13 +135,8 @@ interface ChartTooltipContentProps {
displayCurrency?: string;
}
function formatCurrencyWithCode(value: number, currencyCode: string): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value / 100);
function formatCurrencyWithCode(value: number, currencyCode: string, locale: string): string {
return formatCurrency(value, currencyCode, locale, 0, 0);
}
const ChartTooltipContent = React.forwardRef<
@ -166,6 +163,7 @@ const ChartTooltipContent = React.forwardRef<
ref,
) => {
const { config } = useChart();
const locale = useLocale();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
@ -312,7 +310,7 @@ const ChartTooltipContent = React.forwardRef<
if (original) {
return (
<span className="text-muted-foreground mr-1 text-[10px]">
({formatCurrencyWithCode(original.amount, original.currency_code)})
({formatCurrencyWithCode(original.amount, original.currency_code, locale)})
</span>
);
}
@ -325,7 +323,7 @@ const ChartTooltipContent = React.forwardRef<
)
: typeof item.value ===
'number'
? item.value.toLocaleString()
? item.value.toLocaleString(locale)
: item.value}
</span>
)}
@ -351,6 +349,7 @@ const ChartTooltipContent = React.forwardRef<
{formatCurrencyWithCode(
total,
currency,
locale,
)}
</span>
</div>
@ -365,6 +364,7 @@ const ChartTooltipContent = React.forwardRef<
? formatCurrencyWithCode(
currencyTotals[0][1],
currencyTotals[0][0],
locale,
)
: payload
.reduce(
@ -384,7 +384,7 @@ const ChartTooltipContent = React.forwardRef<
},
0,
)
.toLocaleString()}
.toLocaleString(locale)}
</span>
</div>
)}

View File

@ -1,3 +1,4 @@
import { useLocale } from '@/hooks/use-locale';
import { Account, AccountType, Bank } from '@/types/account';
import { Category } from '@/types/category';
import { format, subDays, subMonths } from 'date-fns';
@ -52,6 +53,7 @@ export interface DashboardData {
export function deriveAccountMetrics(
netWorthEvolution: NetWorthEvolutionData,
locale = 'en-US',
): AccountWithMetrics[] {
const { data, accounts } = netWorthEvolution;
@ -62,7 +64,7 @@ export function deriveAccountMetrics(
return Object.values(accounts).map((account) => {
const investedKey = account.id + '_invested';
const history = data.map((point) => ({
date: formatMonth(point.month as string),
date: formatMonth(point.month as string, locale),
value: (point[account.id] as number) ?? 0,
investedAmount:
investedKey in point
@ -91,14 +93,14 @@ export function deriveAccountMetrics(
});
}
function formatMonth(yearMonth: string): string {
function formatMonth(yearMonth: string, locale = 'en-US'): string {
const [year, month] = yearMonth.split('-');
const date = new Date(parseInt(year), parseInt(month) - 1);
const isCurrentYear = date.getFullYear() === new Date().getFullYear();
return date.toLocaleDateString(
'en-US',
locale,
isCurrentYear
? { month: 'short' }
: { year: '2-digit', month: 'short' },
@ -106,6 +108,7 @@ function formatMonth(yearMonth: string): string {
}
export function useDashboardData(): DashboardData & { refetch: () => void } {
const locale = useLocale();
const [data, setData] = useState<Omit<DashboardData, 'isLoading'>>({
netWorthEvolution: { data: [], accounts: {}, currency_code: 'USD' },
accounts: [],
@ -146,7 +149,7 @@ export function useDashboardData(): DashboardData & { refetch: () => void } {
setData({
netWorthEvolution: netWorthData,
accounts: deriveAccountMetrics(netWorthData),
accounts: deriveAccountMetrics(netWorthData, locale),
topCategories,
});
} catch (error) {

View File

@ -9,6 +9,7 @@ import {
type NetWorthEvolutionData,
deriveAccountMetrics,
} from '@/hooks/use-dashboard-data';
import { useLocale } from '@/hooks/use-locale';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { dashboard } from '@/routes';
import { BreadcrumbItem, SharedData } from '@/types';
@ -41,6 +42,7 @@ interface DashboardProps extends SharedData {
export default function Dashboard() {
const { props } = usePage<DashboardProps>();
const locale = useLocale();
const { isKeySet, encryptedMessageData, fetchEncryptedMessage } =
useEncryptionKey();
const [showUnlockDialog, setShowUnlockDialog] = useState(false);
@ -52,8 +54,8 @@ export default function Dashboard() {
};
const accountMetrics = useMemo(
() => deriveAccountMetrics(netWorthEvolution),
[netWorthEvolution],
() => deriveAccountMetrics(netWorthEvolution, locale),
[netWorthEvolution, locale],
);
const topCategories = props.topCategories ?? [];

View File

@ -12,6 +12,7 @@ import { cn } from '@/lib/utils';
import { checkout } from '@/routes/subscribe';
import { type SharedData } from '@/types';
import { Plan } from '@/types/pricing';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { Head, usePage } from '@inertiajs/react';
import {
@ -38,20 +39,6 @@ interface PaywallPageProps extends SharedData {
stats: PaywallStats;
}
function formatCurrency(
amount: number,
currencyCode: string,
locale: string,
): string {
const absAmount = Math.abs(amount) / 100;
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(absAmount);
}
function getEquivalentBillingLabel(
billingPeriod: string | null,
t: typeof __,
@ -185,7 +172,13 @@ function BalanceDisplay({
<div className="flex flex-col items-center">
{entries.map(([currency, amount]) => (
<span key={currency} className="text-xl font-bold">
{formatCurrency(amount, currency, locale)}
{formatCurrency(
Math.abs(amount),
currency,
locale,
0,
0,
)}
</span>
))}
</div>

View File

@ -1,14 +1,19 @@
export function formatCurrency(
valueInCents: number,
currencyCode = 'USD',
locale = 'en-US',
minimumFractionDigits = 2,
maximumFractionDigits = 2,
): string {
const amount = valueInCents / 100;
return new Intl.NumberFormat('en-US', {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
minimumFractionDigits,
maximumFractionDigits,
})
.format(amount)
.replace(/\s/g, '\u202F');
}
export function getCurrencySymbol(currencyCode: string): string {