diff --git a/lang/es.json b/lang/es.json index 89f0476c..0813cbe6 100644 --- a/lang/es.json +++ b/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" } diff --git a/resources/js/components/accounts/account-balance-chart.tsx b/resources/js/components/accounts/account-balance-chart.tsx index b3ad5c7f..98b6d7fd 100644 --- a/resources/js/components/accounts/account-balance-chart.tsx +++ b/resources/js/components/accounts/account-balance-chart.tsx @@ -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')} = 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( diff --git a/resources/js/components/accounts/balances-modal.tsx b/resources/js/components/accounts/balances-modal.tsx index 3931252b..13482f37 100644 --- a/resources/js/components/accounts/balances-modal.tsx +++ b/resources/js/components/accounts/balances-modal.tsx @@ -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([]); const [isLoading, setIsLoading] = useState(false); const [currentPage, setCurrentPage] = useState(1); @@ -80,10 +83,8 @@ export function BalancesModal({ useState(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({ )} - {formatter.format( - balance.balance / 100, + {formatBalance( + balance.balance, )} {showInvestedAmount && ( {balance.invested_amount !== null - ? formatter.format( - balance.invested_amount / - 100, + ? formatBalance( + balance.invested_amount, ) : '—'} diff --git a/resources/js/components/accounts/import-balances/import-balance-step-mapping.tsx b/resources/js/components/accounts/import-balances/import-balance-step-mapping.tsx index 8e3c657d..c7bea6e8 100644 --- a/resources/js/components/accounts/import-balances/import-balance-step-mapping.tsx +++ b/resources/js/components/accounts/import-balances/import-balance-step-mapping.tsx @@ -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, }; }); diff --git a/resources/js/components/accounts/import-balances/import-balance-step-preview.tsx b/resources/js/components/accounts/import-balances/import-balance-step-preview.tsx index f0ad637a..e39e0072 100644 --- a/resources/js/components/accounts/import-balances/import-balance-step-preview.tsx +++ b/resources/js/components/accounts/import-balances/import-balance-step-preview.tsx @@ -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); diff --git a/resources/js/components/automation-rules/create-automation-rule-dialog.tsx b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx index 4bc68778..b3ed8644 100644 --- a/resources/js/components/automation-rules/create-automation-rule-dialog.tsx +++ b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx @@ -126,7 +126,7 @@ export function CreateAutomationRuleDialog({ {__('Create Automation Rule')} {__( - 'Create a rule to automatically categorize transactions.', + 'Create a rule to automatically categorize transactions and add labels.', )} @@ -194,7 +194,7 @@ export function CreateAutomationRuleDialog({ disabled={isSubmitting} data-testid="submit-automation-rule" > - {isSubmitting ? 'Creating...' : 'Create'} + {isSubmitting ? __('Creating...') : __('Create')} diff --git a/resources/js/components/budgets/budget-list-card.tsx b/resources/js/components/budgets/budget-list-card.tsx index 22f9d404..84d8789d 100644 --- a/resources/js/components/budgets/budget-list-card.tsx +++ b/resources/js/components/budgets/budget-list-card.tsx @@ -102,9 +102,17 @@ export function BudgetListCard({ budget, currencyCode }: Props) { {__('Spent')} - {formatCurrency(stats.totalSpent, currencyCode)}{' '} + {formatCurrency( + stats.totalSpent, + currencyCode, + locale, + )}{' '} {__('of')}{' '} - {formatCurrency(stats.totalAllocated, currencyCode)} + {formatCurrency( + stats.totalAllocated, + currencyCode, + locale, + )} - {formatCurrency(stats.remaining, currencyCode)} + {formatCurrency( + stats.remaining, + currencyCode, + locale, + )} diff --git a/resources/js/components/budgets/budget-spending-chart.tsx b/resources/js/components/budgets/budget-spending-chart.tsx index 5d599f57..90d5fc67 100644 --- a/resources/js/components/budgets/budget-spending-chart.tsx +++ b/resources/js/components/budgets/budget-spending-chart.tsx @@ -77,7 +77,7 @@ function CustomTooltip({ {__('Allocated:')} - {formatCurrency(allocated, currencyCode)} + {formatCurrency(allocated, currencyCode, locale)}
@@ -85,7 +85,7 @@ function CustomTooltip({ {__('Spent:')} - {formatCurrency(spent, currencyCode)} + {formatCurrency(spent, currencyCode, locale)}
{hasPreviousPeriod && data.prevSpent !== undefined && ( @@ -94,7 +94,11 @@ function CustomTooltip({ {__('Last period:')} - {formatCurrency(data.prevSpent, currencyCode)} + {formatCurrency( + data.prevSpent, + currencyCode, + locale, + )} )} @@ -106,7 +110,11 @@ function CustomTooltip({ {percentage}% / - {formatCurrency(available, currencyCode)} + {formatCurrency( + available, + currencyCode, + locale, + )} diff --git a/resources/js/components/charts/mom-chart.tsx b/resources/js/components/charts/mom-chart.tsx index eac8f20b..616b1bfd 100644 --- a/resources/js/components/charts/mom-chart.tsx +++ b/resources/js/components/charts/mom-chart.tsx @@ -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(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); diff --git a/resources/js/components/charts/sankey-chart.tsx b/resources/js/components/charts/sankey-chart.tsx index 63ae061e..b514f462 100644 --- a/resources/js/components/charts/sankey-chart.tsx +++ b/resources/js/components/charts/sankey-chart.tsx @@ -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 (
@@ -111,7 +106,13 @@ function OtherCategoriesBreakdown({
- {formatAmount(item.amount, currency)} + {formatCurrency( + item.amount, + currency, + locale, + 0, + 0, + )} {percentage.toFixed(1)}% @@ -126,7 +127,7 @@ function OtherCategoriesBreakdown({
{__('Total')} - {formatAmount(total, currency)} + {formatCurrency(total, currency, locale, 0, 0)}
@@ -142,6 +143,7 @@ export function SankeyChart({ }: SankeyChartProps) { const [hoveredNode, setHoveredNode] = useState(null); const [hoveredLink, setHoveredLink] = useState(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, + )} ); @@ -520,7 +528,7 @@ export function SankeyChart({ {nodeContent} @@ -536,6 +544,7 @@ export function SankeyChart({ total={otherGroup.total} currency={currency} grandTotal={grandTotal} + locale={locale} /> diff --git a/resources/js/components/onboarding/step-complete.tsx b/resources/js/components/onboarding/step-complete.tsx index 285f6bd9..e314d2d4 100644 --- a/resources/js/components/onboarding/step-complete.tsx +++ b/resources/js/components/onboarding/step-complete.tsx @@ -40,7 +40,7 @@ export function StepComplete() {

{__( - '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!', )}

diff --git a/resources/js/components/onboarding/step-create-account.tsx b/resources/js/components/onboarding/step-create-account.tsx index f890bd2c..8e1315a2 100644 --- a/resources/js/components/onboarding/step-create-account.tsx +++ b/resources/js/components/onboarding/step-create-account.tsx @@ -277,9 +277,9 @@ export function StepCreateAccount({ {isFirstAccount && (

- {__('Your first account must be a')}{' '} - {__('Checking')}{' '} - {__('account.')} + {__( + 'Your first account must be a checking account.', + )}

)} diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index af43627a..f7263bc5 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -722,10 +722,12 @@ export function EditTransactionDialog({ ) : (
{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')}
)} diff --git a/resources/js/components/transactions/import-step-mapping.tsx b/resources/js/components/transactions/import-step-mapping.tsx index f09e6263..57ada1b9 100644 --- a/resources/js/components/transactions/import-step-mapping.tsx +++ b/resources/js/components/transactions/import-step-mapping.tsx @@ -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', }; }); diff --git a/resources/js/components/ui/amount-display.tsx b/resources/js/components/ui/amount-display.tsx index c0df8d03..9d760d95 100644 --- a/resources/js/components/ui/amount-display.tsx +++ b/resources/js/components/ui/amount-display.tsx @@ -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 ''; diff --git a/resources/js/components/ui/amount-input.tsx b/resources/js/components/ui/amount-input.tsx index 2480661c..5b036015 100644 --- a/resources/js/components/ui/amount-input.tsx +++ b/resources/js/components/ui/amount-input.tsx @@ -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 = { - 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( }, ref, ) => { + const locale = useLocale(); const [displayValue, setDisplayValue] = React.useState(''); const [isFocused, setIsFocused] = React.useState(false); @@ -166,7 +176,7 @@ export const AmountInput = React.forwardRef( if (value === 0) { setDisplayValue(''); } else { - setDisplayValue(formatCurrency(value)); + setDisplayValue(formatCurrency(value, locale)); } } }, [value, isFocused]); @@ -205,13 +215,15 @@ export const AmountInput = React.forwardRef( } }; - const currencySymbol = getCurrencySymbol(currencyCode); + const { symbol: currencySymbol, position: symbolPosition } = getCurrencyInfo(currencyCode, locale); return (
- - {currencySymbol} - + {symbolPosition === 'prefix' && ( + + {currencySymbol} + + )} ( 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' && ( + + {currencySymbol} + + )}
); }, diff --git a/resources/js/components/ui/chart.tsx b/resources/js/components/ui/chart.tsx index 4602e25e..a8e9e6c3 100644 --- a/resources/js/components/ui/chart.tsx +++ b/resources/js/components/ui/chart.tsx @@ -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 ( - ({formatCurrencyWithCode(original.amount, original.currency_code)}) + ({formatCurrencyWithCode(original.amount, original.currency_code, locale)}) ); } @@ -325,7 +323,7 @@ const ChartTooltipContent = React.forwardRef< ) : typeof item.value === 'number' - ? item.value.toLocaleString() + ? item.value.toLocaleString(locale) : item.value} )} @@ -351,6 +349,7 @@ const ChartTooltipContent = React.forwardRef< {formatCurrencyWithCode( total, currency, + locale, )} @@ -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)} )} diff --git a/resources/js/hooks/use-dashboard-data.ts b/resources/js/hooks/use-dashboard-data.ts index 5e475751..ea28def3 100644 --- a/resources/js/hooks/use-dashboard-data.ts +++ b/resources/js/hooks/use-dashboard-data.ts @@ -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>({ 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) { diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index e2ac8069..ae376a28 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -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(); + 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 ?? []; diff --git a/resources/js/pages/subscription/paywall.tsx b/resources/js/pages/subscription/paywall.tsx index 90060938..ca792529 100644 --- a/resources/js/pages/subscription/paywall.tsx +++ b/resources/js/pages/subscription/paywall.tsx @@ -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({
{entries.map(([currency, amount]) => ( - {formatCurrency(amount, currency, locale)} + {formatCurrency( + Math.abs(amount), + currency, + locale, + 0, + 0, + )} ))}
diff --git a/resources/js/utils/currency.ts b/resources/js/utils/currency.ts index 57ec470c..998ea100 100644 --- a/resources/js/utils/currency.ts +++ b/resources/js/utils/currency.ts @@ -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 {