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:
parent
77b225d747
commit
2b9fd2384a
24
lang/es.json
24
lang/es.json
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue