diff --git a/frontend/src/components/BudgetSection.tsx b/frontend/src/components/BudgetSection.tsx index 7540659..8596ab6 100644 --- a/frontend/src/components/BudgetSection.tsx +++ b/frontend/src/components/BudgetSection.tsx @@ -6,6 +6,7 @@ import { Input } from "./ui/Input"; import { Button } from "./ui/Button"; import { ProgressBar } from "./ui/ProgressBar"; import { Modal } from "./ui/Modal"; +import { useCurrency } from "../context/CurrencyContext"; interface BudgetSectionProps { monthId: number; @@ -22,6 +23,7 @@ export function BudgetSection({ isReadOnly, onUpdate, }: BudgetSectionProps) { + const { formatCurrency } = useCurrency(); const [isManaging, setIsManaging] = useState(false); const [isAddingCategory, setIsAddingCategory] = useState(false); const [editingCategoryId, setEditingCategoryId] = useState(null); @@ -131,7 +133,7 @@ export function BudgetSection({
- ${budget.spent_amount.toFixed(2)} / ${budget.allocated_amount.toFixed(2)} + {formatCurrency(budget.spent_amount)} / {formatCurrency(budget.allocated_amount)} {!isReadOnly && (
))} @@ -98,7 +100,7 @@ export function FixedExpenses({ expenses, onUpdate }: FixedExpensesProps) { Total - ${total.toFixed(2)} + {formatCurrency(total)} )} @@ -142,7 +144,7 @@ export function FixedExpenses({ expenses, onUpdate }: FixedExpensesProps) {
{expense.label}
- ${expense.amount.toFixed(2)} + {formatCurrency(expense.amount)}
diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index c0f75e8..c9f4fe9 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -11,6 +11,7 @@ import { import { api, StatsResponse } from "../api/client"; import { Modal } from "./ui/Modal"; import { Button } from "./ui/Button"; +import { useCurrency } from "../context/CurrencyContext"; const MONTH_NAMES = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", @@ -18,6 +19,7 @@ const MONTH_NAMES = [ ]; export function Stats() { + const { formatCurrency, getCurrencySymbol } = useCurrency(); const [isOpen, setIsOpen] = useState(false); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(false); @@ -67,7 +69,7 @@ export function Stats() { Avg Monthly Spending
- ${stats.average_monthly_spending.toFixed(2)} + {formatCurrency(stats.average_monthly_spending)}
@@ -75,7 +77,7 @@ export function Stats() { Avg Monthly Income
- ${stats.average_monthly_income.toFixed(2)} + {formatCurrency(stats.average_monthly_income)}
@@ -143,7 +145,7 @@ export function Stats() {
- ${cat.current_month_spent.toFixed(2)} + {formatCurrency(cat.current_month_spent)} {cat.change_amount !== 0 && (
)}
diff --git a/frontend/src/components/Summary.tsx b/frontend/src/components/Summary.tsx index 62b16ae..b2a76bd 100644 --- a/frontend/src/components/Summary.tsx +++ b/frontend/src/components/Summary.tsx @@ -1,6 +1,7 @@ import { TrendingDown, Wallet, CreditCard, PiggyBank } from "lucide-react"; import { Card } from "./ui/Card"; import { ReactNode } from "react"; +import { useCurrency } from "../context/CurrencyContext"; interface SummaryProps { totalIncome: number; @@ -11,6 +12,7 @@ interface SummaryProps { } export function Summary({ totalIncome, totalFixed, totalSpent, remaining, extraCard }: SummaryProps) { + const { formatCurrency } = useCurrency(); const isPositive = remaining >= 0; const items = [ @@ -53,7 +55,7 @@ export function Summary({ totalIncome, totalFixed, totalSpent, remaining, extraC {item.label}
- ${Math.abs(item.value).toFixed(2)} + {formatCurrency(item.value, { absolute: true })} {item.label === "Remaining" && item.value < 0 && ( deficit )} diff --git a/frontend/src/components/VarianceModal.tsx b/frontend/src/components/VarianceModal.tsx index b0f6f78..11d43eb 100644 --- a/frontend/src/components/VarianceModal.tsx +++ b/frontend/src/components/VarianceModal.tsx @@ -1,6 +1,7 @@ import { Modal } from "./ui/Modal"; import { MonthlyBudgetWithCategory } from "../api/client"; import { TrendingUp, TrendingDown, AlertCircle, PartyPopper } from "lucide-react"; +import { useCurrency } from "../context/CurrencyContext"; interface VarianceModalProps { isOpen: boolean; @@ -27,6 +28,7 @@ export function VarianceModal({ totalFixed, totalBudgeted, }: VarianceModalProps) { + const { formatCurrency } = useCurrency(); const overBudget: BudgetVariance[] = []; const underBudget: BudgetVariance[] = []; const unplanned: BudgetVariance[] = []; @@ -76,7 +78,7 @@ export function VarianceModal({

{netVariance < 0 && (

- You've saved ${Math.abs(netVariance).toFixed(2)} more than planned across your categories. + You've saved {formatCurrency(Math.abs(netVariance))} more than planned across your categories.

)} {underBudget.length > 0 && ( @@ -91,7 +93,7 @@ export function VarianceModal({

- You're ${(totalOverspend + totalUnplanned + incomeShortfall).toFixed(2)} over budget + You're {formatCurrency(totalOverspend + totalUnplanned + incomeShortfall)} over budget

Here's what's affecting your projected savings: @@ -115,10 +117,10 @@ export function VarianceModal({ {item.label}

- +${item.variance.toFixed(2)} + +{formatCurrency(item.variance)} - (${item.spent.toFixed(2)} / ${item.allocated.toFixed(2)}) + ({formatCurrency(item.spent)} / {formatCurrency(item.allocated)})
@@ -141,7 +143,7 @@ export function VarianceModal({ > {item.label} - ${item.spent.toFixed(2)} + {formatCurrency(item.spent)}
))} @@ -157,10 +159,10 @@ export function VarianceModal({

- Income is ${incomeShortfall.toFixed(2)} less than needed to cover expenses + Income is {formatCurrency(incomeShortfall)} less than needed to cover expenses

- Income: ${totalIncome.toFixed(2)} | Needed: ${incomeNeeded.toFixed(2)} + Income: {formatCurrency(totalIncome)} | Needed: {formatCurrency(incomeNeeded)}

@@ -181,10 +183,10 @@ export function VarianceModal({ {item.label}
- -${Math.abs(item.variance).toFixed(2)} + -{formatCurrency(Math.abs(item.variance))} - (${item.spent.toFixed(2)} / ${item.allocated.toFixed(2)}) + ({formatCurrency(item.spent)} / {formatCurrency(item.allocated)})
@@ -202,19 +204,19 @@ export function VarianceModal({
Total over budget: - +${(totalOverspend + totalUnplanned).toFixed(2)} + +{formatCurrency(totalOverspend + totalUnplanned)}
Total under budget: - -${totalSaved.toFixed(2)} + -{formatCurrency(totalSaved)}
Net impact: 0 ? "text-terracotta-600 dark:text-terracotta-400" : "text-sage-600 dark:text-sage-400"}`}> - {netVariance > 0 ? "+" : "-"}${Math.abs(netVariance).toFixed(2)} + {netVariance > 0 ? "+" : "-"}{formatCurrency(Math.abs(netVariance))}
diff --git a/frontend/src/context/CurrencyContext.tsx b/frontend/src/context/CurrencyContext.tsx new file mode 100644 index 0000000..1afc2c8 --- /dev/null +++ b/frontend/src/context/CurrencyContext.tsx @@ -0,0 +1,171 @@ +import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from "react"; + +export interface Currency { + code: string; + symbol: string; + name: string; + locale: string; + position: "before" | "after"; +} + +export const SUPPORTED_CURRENCIES: Currency[] = [ + { code: "USD", symbol: "$", name: "US Dollar", locale: "en-US", position: "before" }, + { code: "EUR", symbol: "€", name: "Euro", locale: "de-DE", position: "after" }, + { code: "GBP", symbol: "£", name: "British Pound", locale: "en-GB", position: "before" }, + { code: "JPY", symbol: "¥", name: "Japanese Yen", locale: "ja-JP", position: "before" }, + { code: "CAD", symbol: "CA$", name: "Canadian Dollar", locale: "en-CA", position: "before" }, + { code: "AUD", symbol: "A$", name: "Australian Dollar", locale: "en-AU", position: "before" }, + { code: "CHF", symbol: "CHF", name: "Swiss Franc", locale: "de-CH", position: "after" }, + { code: "CNY", symbol: "¥", name: "Chinese Yuan", locale: "zh-CN", position: "before" }, + { code: "INR", symbol: "₹", name: "Indian Rupee", locale: "en-IN", position: "before" }, + { code: "MXN", symbol: "MX$", name: "Mexican Peso", locale: "es-MX", position: "before" }, + { code: "BRL", symbol: "R$", name: "Brazilian Real", locale: "pt-BR", position: "before" }, + { code: "KRW", symbol: "₩", name: "South Korean Won", locale: "ko-KR", position: "before" }, + { code: "MYR", symbol: "RM", name: "Malaysian Ringgit", locale: "ms-MY", position: "before" }, + { code: "EGP", symbol: "EGP", name: "Egyptian Pound", locale: "en-EG", position: "before" }, + { code: "SAR", symbol: "SAR", name: "Saudi Riyal", locale: "en-SA", position: "before" }, +]; + +interface CurrencyContextType { + currency: Currency; + setCurrency: (code: string) => void; + formatCurrency: (value: number, options?: FormatOptions) => string; + formatCurrencyCompact: (value: number) => string; + getCurrencySymbol: () => string; +} + +interface FormatOptions { + showSymbol?: boolean; + absolute?: boolean; +} + +const STORAGE_KEY = "currency"; +const DEFAULT_CURRENCY_CODE = "USD"; + +function getDefaultCurrency(): Currency { + // Try to detect from browser locale + const browserLocale = navigator.language || "en-US"; + + // Map common locales to currencies + const localeMap: Record = { + "en-US": "USD", + "en-GB": "GBP", + "de-DE": "EUR", + "fr-FR": "EUR", + "es-ES": "EUR", + "it-IT": "EUR", + "ja-JP": "JPY", + "en-CA": "CAD", + "en-AU": "AUD", + "de-CH": "CHF", + "zh-CN": "CNY", + "en-IN": "INR", + "es-MX": "MXN", + "pt-BR": "BRL", + "ko-KR": "KRW", + "ms-MY": "MYR", + "en-MY": "MYR", + "en-EG": "EGP", + "en-SA": "SAR", + }; + + const detectedCode = localeMap[browserLocale] || DEFAULT_CURRENCY_CODE; + return SUPPORTED_CURRENCIES.find(c => c.code === detectedCode) || SUPPORTED_CURRENCIES[0]; +} + +function getCurrencyByCode(code: string): Currency { + return SUPPORTED_CURRENCIES.find(c => c.code === code) || SUPPORTED_CURRENCIES[0]; +} + +const CurrencyContext = createContext(undefined); + +export function CurrencyProvider({ children }: { children: ReactNode }) { + const [currency, setCurrencyState] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const found = SUPPORTED_CURRENCIES.find(c => c.code === stored); + if (found) return found; + } + return getDefaultCurrency(); + }); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, currency.code); + }, [currency]); + + const setCurrency = useCallback((code: string) => { + const newCurrency = getCurrencyByCode(code); + setCurrencyState(newCurrency); + }, []); + + const formatCurrency = useCallback((value: number, options: FormatOptions = {}): string => { + const { showSymbol = true, absolute = false } = options; + const displayValue = absolute ? Math.abs(value) : value; + + try { + // Use Intl.NumberFormat for proper locale-aware formatting + const formatter = new Intl.NumberFormat(currency.locale, { + style: showSymbol ? "currency" : "decimal", + currency: currency.code, + minimumFractionDigits: currency.code === "JPY" || currency.code === "KRW" ? 0 : 2, + maximumFractionDigits: currency.code === "JPY" || currency.code === "KRW" ? 0 : 2, + }); + + return formatter.format(displayValue); + } catch { + // Fallback formatting if Intl fails + const numStr = displayValue.toFixed(currency.code === "JPY" || currency.code === "KRW" ? 0 : 2); + if (!showSymbol) return numStr; + return currency.position === "before" + ? `${currency.symbol}${numStr}` + : `${numStr} ${currency.symbol}`; + } + }, [currency]); + + const formatCurrencyCompact = useCallback((value: number): string => { + try { + const formatter = new Intl.NumberFormat(currency.locale, { + style: "currency", + currency: currency.code, + notation: "compact", + minimumFractionDigits: 0, + maximumFractionDigits: 1, + }); + return formatter.format(value); + } catch { + // Fallback + if (Math.abs(value) >= 1000000) { + return `${currency.symbol}${(value / 1000000).toFixed(1)}M`; + } else if (Math.abs(value) >= 1000) { + return `${currency.symbol}${(value / 1000).toFixed(1)}K`; + } + return formatCurrency(value); + } + }, [currency, formatCurrency]); + + const getCurrencySymbol = useCallback((): string => { + return currency.symbol; + }, [currency]); + + return ( + + {children} + + ); +} + +export function useCurrency() { + const context = useContext(CurrencyContext); + if (!context) { + throw new Error("useCurrency must be used within CurrencyProvider"); + } + return context; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 20e22e0..fda57bc 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,13 +4,16 @@ import "./index.css"; import App from "./App"; import { ThemeProvider } from "./context/ThemeContext"; import { AuthProvider } from "./context/AuthContext"; +import { CurrencyProvider } from "./context/CurrencyContext"; createRoot(document.getElementById("root")!).render( - - - + + + + + ); diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 2919f6a..3f2c58b 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -2,8 +2,10 @@ import { useState } from "react"; import { Layout } from "../components/Layout"; import { Button } from "../components/ui/Button"; import { Input } from "../components/ui/Input"; +import { Select } from "../components/ui/Select"; import { Modal } from "../components/ui/Modal"; import { useAuth } from "../context/AuthContext"; +import { useCurrency, SUPPORTED_CURRENCIES } from "../context/CurrencyContext"; import { api } from "../api/client"; import { ArrowLeft } from "lucide-react"; @@ -13,6 +15,7 @@ interface SettingsProps { export function Settings({ onBack }: SettingsProps) { const { user, logout, updateUsername } = useAuth(); + const { currency, setCurrency, formatCurrency } = useCurrency(); const [newUsername, setNewUsername] = useState(user?.username || ""); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); @@ -27,6 +30,8 @@ export function Settings({ onBack }: SettingsProps) { const [deleteError, setDeleteError] = useState(""); const [usernameSuccess, setUsernameSuccess] = useState(false); const [passwordSuccess, setPasswordSuccess] = useState(false); + const [currencySuccess, setCurrencySuccess] = useState(false); + const [selectedCurrency, setSelectedCurrency] = useState(currency.code); const handleChangeUsername = async (e: React.FormEvent) => { e.preventDefault(); @@ -99,6 +104,11 @@ export function Settings({ onBack }: SettingsProps) { } }; + const handleSaveCurrency = () => { + setCurrency(selectedCurrency); + setCurrencySuccess(true); + }; + return (
@@ -115,6 +125,34 @@ export function Settings({ onBack }: SettingsProps) {
+
+

+ Currency +

+
+