From 5d2b131bbb60630d4e02a6364b110acbc5f737c9 Mon Sep 17 00:00:00 2001 From: krokosik Date: Fri, 17 Oct 2025 19:48:49 +0200 Subject: [PATCH 01/14] Hide confusing share info when split is incorrect --- src/components/AddExpense/SplitTypeSection.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/AddExpense/SplitTypeSection.tsx b/src/components/AddExpense/SplitTypeSection.tsx index 6f004da5..7639c361 100644 --- a/src/components/AddExpense/SplitTypeSection.tsx +++ b/src/components/AddExpense/SplitTypeSection.tsx @@ -376,17 +376,18 @@ export const UserAndAmount: React.FC<{ user: Participant; currency: string }> = user, currency, }) => { + const canSplitScreenClosed = useAddExpenseStore((s) => s.canSplitScreenClosed); const paidBy = useAddExpenseStore((s) => s.paidBy); const amount = useAddExpenseStore((s) => s.amount); const shareAmount = paidBy?.id === user.id ? (user.amount ?? 0n) - amount : user.amount; return ( -
+

{user.name ?? user.email}

-

+

{0n < (shareAmount ?? 0n) ? '-' : ''} {currency} {toUIString(shareAmount)}

From 1c84e8a55fc59d8f2ae7992550d208da3906564c Mon Sep 17 00:00:00 2001 From: krokosik Date: Fri, 17 Oct 2025 20:17:06 +0200 Subject: [PATCH 02/14] Minor UI improvements --- .../AddExpense/SplitTypeSection.tsx | 58 ++++++++++++------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/src/components/AddExpense/SplitTypeSection.tsx b/src/components/AddExpense/SplitTypeSection.tsx index 7639c361..e1824240 100644 --- a/src/components/AddExpense/SplitTypeSection.tsx +++ b/src/components/AddExpense/SplitTypeSection.tsx @@ -21,6 +21,7 @@ import { EntityAvatar } from '../ui/avatar'; import { AppDrawer, DrawerClose } from '../ui/drawer'; import { Input } from '../ui/input'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; +import { cn } from '~/lib/utils'; export const SplitTypeSection: React.FC = () => { const { t, displayName, generateSplitDescription } = useTranslationWithUtils(['expense_details']); @@ -139,6 +140,7 @@ interface SplitSectionPropsBase { iconComponent: LucideIcon; prefix: string; isBoolean?: boolean; + isCurrency?: boolean; fmtSummartyText: ( amount: bigint, currency: string, @@ -154,12 +156,20 @@ interface BooleanSplitSectionProps extends SplitSectionPropsBase { } interface NumericSplitSectionProps extends SplitSectionPropsBase { - isBoolean: false; fmtShareText: (share: bigint) => string; step: number | null; } -type SplitSectionProps = BooleanSplitSectionProps | NumericSplitSectionProps; +interface CurrencySplitSectionProps extends SplitSectionPropsBase { + isCurrency: true; + fmtShareText: (share: bigint) => string; + step: number | null; +} + +type SplitSectionProps = + | BooleanSplitSectionProps + | NumericSplitSectionProps + | CurrencySplitSectionProps; const CURRENCY_TOKEN = '__CURRENCY__'; @@ -182,7 +192,6 @@ const getSplitProps = (t: TFunction): SplitSectionProps[] => [ splitType: SplitType.PERCENTAGE, iconComponent: Percent, prefix: '%', - isBoolean: false, fmtSummartyText: (amount, currency, participants, splitShares) => { const remainingPercentage = 10000n - @@ -199,7 +208,7 @@ const getSplitProps = (t: TFunction): SplitSectionProps[] => [ splitType: SplitType.EXACT, iconComponent: DollarSign, prefix: CURRENCY_TOKEN, - isBoolean: false, + isCurrency: true, fmtSummartyText: (amount, currency, participants, splitShares) => { const totalAmount = participants.reduce( (acc, p) => acc + (splitShares[p.id]?.[SplitType.EXACT] ?? 0n), @@ -214,7 +223,6 @@ const getSplitProps = (t: TFunction): SplitSectionProps[] => [ splitType: SplitType.SHARE, iconComponent: BarChart2, prefix: t('ui.add_expense_details.split_type_section.types.share.shares'), - isBoolean: false, fmtSummartyText: (_amount, _currency, participants, splitShares) => { const totalShares = participants.reduce( (acc, p) => acc + (splitShares[p.id]?.[SplitType.SHARE] ?? 0n), @@ -229,8 +237,16 @@ const getSplitProps = (t: TFunction): SplitSectionProps[] => [ splitType: SplitType.ADJUSTMENT, iconComponent: Plus, prefix: CURRENCY_TOKEN, - isBoolean: false, - fmtSummartyText: () => '', + isCurrency: true, + fmtSummartyText: (amount, _c, _p, splitShares) => { + const totalAdjustment = Object.values(splitShares).reduce( + (acc, shares) => acc + (shares[SplitType.ADJUSTMENT] ?? 0n), + 0n, + ); + return totalAdjustment > amount + ? `Total adjustment exceeds amount by ${toUIString(totalAdjustment - amount, true)}` + : ' '; + }, fmtShareText: (share) => removeTrailingZeros(toUIString(share)), step: null, }, @@ -284,7 +300,9 @@ const SplitSection: React.FC = (props) => { return (
-
{summaryText}
+
+ {summaryText} +
{isBoolean && (
@@ -316,6 +334,7 @@ const ParticipantRow = ({ p, prefix, isBoolean, + isCurrency, share, currency, onToggleBoolean, @@ -354,19 +373,16 @@ const ParticipantRow = ({ ) : null ) : ( -
-

{prefix.replace(CURRENCY_TOKEN, currency)}

- -
+ )}
); From 6b1de0d0dae463492d0a4296164c309e4a79f657 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sat, 18 Oct 2025 17:15:12 +0200 Subject: [PATCH 03/14] Use localized currency helpers and new currency input type --- src/components/AddExpense/AddExpensePage.tsx | 37 ++- src/components/Expense/BalanceEntry.tsx | 10 +- src/components/Expense/BalanceList.tsx | 10 +- src/components/Expense/ExpenseDetails.tsx | 21 +- src/components/Expense/ExpenseList.tsx | 10 +- src/components/Friend/Export.tsx | 13 +- src/components/Friend/FriendBalance.tsx | 9 +- src/components/Friend/GroupSettleup.tsx | 93 +++----- src/components/Friend/Settleup.tsx | 187 ++++++--------- src/components/group/GroupMyBalance.tsx | 12 +- src/components/ui/currency-input.tsx | 60 +++++ src/components/ui/drawer.tsx | 4 +- src/contexts/CurrencyHelpersContext.tsx | 56 +++++ src/hooks/useTranslationWithUtils.ts | 42 +++- src/pages/_app.tsx | 19 +- src/pages/activity.tsx | 91 ++++---- src/pages/add.tsx | 8 +- src/pages/balances.tsx | 9 +- src/pages/balances/[friendId].tsx | 19 +- src/pages/groups/[groupId].tsx | 6 +- .../api/services/notificationService.ts | 7 +- src/server/api/services/splitService.ts | 15 +- src/utils/numbers.ts | 217 ++++++++++++++---- src/utils/strings.ts | 2 +- 24 files changed, 608 insertions(+), 349 deletions(-) create mode 100644 src/components/ui/currency-input.tsx create mode 100644 src/contexts/CurrencyHelpersContext.tsx diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index 8dab0a5f..d1810d5a 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -8,7 +8,6 @@ import { type CurrencyCode } from '~/lib/currency'; import { cn } from '~/lib/utils'; import { useAddExpenseStore } from '~/store/addStore'; import { api } from '~/utils/api'; -import { toSafeBigInt } from '~/utils/numbers'; import { Button } from '../ui/button'; import { Calendar } from '../ui/calendar'; @@ -22,13 +21,13 @@ import { UploadFile } from './UploadFile'; import { UserInput } from './UserInput'; import { toast } from 'sonner'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { CurrencyInput } from '../ui/currency-input'; export const AddOrEditExpensePage: React.FC<{ isStorageConfigured: boolean; enableSendingInvites: boolean; expenseId?: string; }> = ({ isStorageConfigured, enableSendingInvites, expenseId }) => { - const { t, toUIDate } = useTranslationWithUtils(['expense_details']); const showFriends = useAddExpenseStore((s) => s.showFriends); const amount = useAddExpenseStore((s) => s.amount); const isNegative = useAddExpenseStore((s) => s.isNegative); @@ -45,6 +44,8 @@ export const AddOrEditExpensePage: React.FC<{ const splitType = useAddExpenseStore((s) => s.splitType); const fileKey = useAddExpenseStore((s) => s.fileKey); + const { t, toUIDate } = useTranslationWithUtils('expense_details'); + const { setCurrency, setCategory, @@ -71,10 +72,13 @@ export const AddOrEditExpensePage: React.FC<{ const router = useRouter(); const onUpdateAmount = useCallback( - (amt: string) => { - const _amt = amt.replace(',', '.'); - setAmountStr(_amt); - setAmount(toSafeBigInt(_amt)); + ({ strValue, bigIntValue }: { strValue?: string; bigIntValue?: bigint }) => { + if (strValue !== undefined) { + setAmountStr(strValue); + } + if (bigIntValue !== undefined) { + setAmount(bigIntValue); + } }, [setAmount, setAmountStr], ); @@ -156,14 +160,6 @@ export const AddOrEditExpensePage: React.FC<{ [setDescription], ); - const onAmountChange = useCallback( - (e: React.ChangeEvent) => { - const { value } = e.target; - onUpdateAmount(value); - }, - [onUpdateAmount], - ); - return (
@@ -203,13 +199,14 @@ export const AddOrEditExpensePage: React.FC<{
-
diff --git a/src/components/Expense/BalanceEntry.tsx b/src/components/Expense/BalanceEntry.tsx index ce26dfe9..b67c9ac1 100644 --- a/src/components/Expense/BalanceEntry.tsx +++ b/src/components/Expense/BalanceEntry.tsx @@ -1,9 +1,8 @@ -import { useTranslation } from 'next-i18next'; +import { clsx } from 'clsx'; import Link from 'next/link'; import { useRouter } from 'next/router'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { EntityAvatar } from '../ui/avatar'; -import { clsx } from 'clsx'; -import { toUIString } from '~/utils/numbers'; export const BalanceEntry: React.FC<{ entity: { name?: string | null; image?: string | null; email?: string | null }; @@ -13,7 +12,8 @@ export const BalanceEntry: React.FC<{ id: number; hasMore?: boolean; }> = ({ entity, amount, isPositive, currency, id, hasMore }) => { - const { t } = useTranslation('common'); + const { t, getCurrencyHelpersCached } = useTranslationWithUtils('common'); + const { toUIString } = getCurrencyHelpersCached(currency); const router = useRouter(); const currentRoute = router.pathname; @@ -39,7 +39,7 @@ export const BalanceEntry: React.FC<{ {t('ui.actors.you')} {t(`ui.expense.you.${isPositive ? 'lent' : 'owe'}`)}
- {currency} {toUIString(amount)} + {toUIString(amount)} {hasMore ? '*' : ''}
diff --git a/src/components/Expense/BalanceList.tsx b/src/components/Expense/BalanceList.tsx index 7c6b5d9f..12bf57e5 100644 --- a/src/components/Expense/BalanceList.tsx +++ b/src/components/Expense/BalanceList.tsx @@ -4,7 +4,7 @@ import { Info } from 'lucide-react'; import { Fragment, useMemo } from 'react'; import { EntityAvatar } from '~/components/ui/avatar'; import { api } from '~/utils/api'; -import { BigMath, toUIString } from '~/utils/numbers'; +import { BigMath } from '~/utils/numbers'; import { GroupSettleUp } from '../Friend/GroupSettleup'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion'; @@ -20,7 +20,7 @@ export const BalanceList: React.FC<{ groupBalances?: GroupBalance[]; users?: User[]; }> = ({ groupBalances = [], users = [] }) => { - const { displayName, t } = useTranslationWithUtils(['expense_details']); + const { displayName, t, getCurrencyHelpersCached } = useTranslationWithUtils('expense_details'); const userQuery = api.user.me.useQuery(); const userMap = useMemo(() => { @@ -90,7 +90,9 @@ export const BalanceList: React.FC<{ 0 < totalAmount[1] ? 'text-emerald-500' : 'text-orange-600', )} > - {toUIString(totalAmount[1])} {totalAmount[0]} + {getCurrencyHelpersCached(totalAmount[0]).toUIString( + BigMath.abs(totalAmount[1]), + )} )} @@ -129,7 +131,7 @@ export const BalanceList: React.FC<{ 0 < amount ? 'text-emerald-500' : 'text-orange-600', )} > - {toUIString(amount)} {currency} + {getCurrencyHelpersCached(currency).toUIString(BigMath.abs(amount))} {' '} diff --git a/src/components/Expense/ExpenseDetails.tsx b/src/components/Expense/ExpenseDetails.tsx index 367fac90..8baad53a 100644 --- a/src/components/Expense/ExpenseDetails.tsx +++ b/src/components/Expense/ExpenseDetails.tsx @@ -2,8 +2,6 @@ import { type Expense, type ExpenseParticipant, type User } from '@prisma/client import { isSameDay } from 'date-fns'; import { type User as NextUser } from 'next-auth'; -import { toUIString } from '~/utils/numbers'; - import { EntityAvatar } from '../ui/avatar'; import { Separator } from '../ui/separator'; import { Receipt } from './Receipt'; @@ -24,7 +22,10 @@ interface ExpenseDetailsProps { } const ExpenseDetails: FC = ({ user, expense, storagePublicUrl }) => { - const { displayName, toUIDate, t } = useTranslationWithUtils(['expense_details']); + const { displayName, toUIDate, t, getCurrencyHelpersCached } = + useTranslationWithUtils('expense_details'); + const { toUIString } = getCurrencyHelpersCached(expense.currency); + return ( <>
@@ -34,9 +35,7 @@ const ExpenseDetails: FC = ({ user, expense, storagePublicU

{expense.name}

-

- {expense.currency} {toUIString(expense.amount)} -

+

{toUIString(expense.amount)}

{!isSameDay(expense.expenseDate, expense.createdAt) ? (

{toUIDate(expense.expenseDate, { year: true })} @@ -90,7 +89,7 @@ const ExpenseDetails: FC = ({ user, expense, storagePublicU `ui.expense.${expense.paidByUser.id === user.id ? 'you' : 'user'}.${expense.amount < 0 ? 'received' : 'paid'}`, { ns: 'common' }, )}{' '} - {expense.currency} {toUIString(expense.amount)} + {toUIString(expense.amount)}

@@ -106,16 +105,12 @@ const ExpenseDetails: FC = ({ user, expense, storagePublicU

{displayName(partecipant.user, user.id)}{' '} {t( - `ui.expense.${user.id === partecipant.userId ? 'you' : 'user'}.${expense.amount < 0 ? 'received' : 'owe'}`, + `ui.expense.${user.id === partecipant.userId ? 'you' : 'user'}.${partecipant.amount > 0 ? 'get' : 'owe'}`, { ns: 'common', }, )}{' '} - {expense.currency}{' '} - {toUIString( - (expense.paidBy === partecipant.userId ? (expense.amount ?? 0n) : 0n) - - partecipant.amount, - )} + {toUIString(partecipant.amount)}

))} diff --git a/src/components/Expense/ExpenseList.tsx b/src/components/Expense/ExpenseList.tsx index 44c62600..e47182c4 100644 --- a/src/components/Expense/ExpenseList.tsx +++ b/src/components/Expense/ExpenseList.tsx @@ -5,7 +5,6 @@ import Link from 'next/link'; import React from 'react'; import { CategoryIcon } from '~/components/ui/categoryIcons'; import type { ExpenseRouter } from '~/server/api/routers/expense'; -import { toUIString } from '~/utils/numbers'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; export const ExpenseList: React.FC<{ @@ -17,8 +16,9 @@ export const ExpenseList: React.FC<{ isGroup?: boolean; isLoading?: boolean; }> = ({ userId, isGroup = false, expenses = [], contactId, isLoading }) => { - const { displayName, toUIDate, t } = useTranslationWithUtils(['expense_details']); - + const { displayName, toUIDate, t, getCurrencyHelpersCached } = useTranslationWithUtils([ + 'expense_details', + ]); return ( <> {expenses.map((e) => { @@ -30,6 +30,7 @@ export const ExpenseList: React.FC<{ const yourExpenseAmount = youPaid ? (yourExpense?.amount ?? 0n) : -(yourExpense?.amount ?? 0n); + const { toUIString } = getCurrencyHelpersCached(e.currency); return ( {isSettlement ? ' 🎉 ' : null}
{displayName(e.paidByUser, userId)}{' '} {t(`ui.expense.user.${e.amount < 0n ? 'received' : 'paid'}`, { ns: 'common' })}{' '} - {e.currency} {toUIString(e.amount)} + {toUIString(e.amount)}

@@ -73,7 +74,6 @@ export const ExpenseList: React.FC<{
- {e.currency}{' '} {toUIString(yourExpenseAmount)}
diff --git a/src/components/Friend/Export.tsx b/src/components/Friend/Export.tsx index 3421d356..4b93450c 100644 --- a/src/components/Friend/Export.tsx +++ b/src/components/Friend/Export.tsx @@ -4,7 +4,7 @@ import { Download } from 'lucide-react'; import React from 'react'; import { Button } from '~/components/ui/button'; -import { toUIString } from '~/utils/numbers'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; interface ExportCSVProps { expenses?: (Expense & { expenseParticipants: ExpenseParticipant[] })[]; @@ -36,6 +36,8 @@ export const Export: React.FC = ({ 'Settlement', ]; + const { getCurrencyHelpersCached } = useTranslationWithUtils('common'); + const exportToCSV = () => { const csvHeaders = headers.join(','); const csvData = expenses.map((expense) => { @@ -45,18 +47,19 @@ export const Export: React.FC = ({ ); const isSettlement = expense.splitType === SplitType.SETTLEMENT; + const { parseToCleanString } = getCurrencyHelpersCached(expense.currency); return [ expense.paidBy === currentUserId ? 'You' : friendName, expense.name, expense.category, - toUIString(expense?.amount), + parseToCleanString(expense?.amount), expense.splitType, format(new Date(expense.expenseDate), 'yyyy-MM-dd HH:mm:ss'), expense.currency, - youPaid && !isSettlement ? toUIString(yourExpense?.amount) : 0n, - !youPaid && !isSettlement ? toUIString(yourExpense?.amount) : 0n, - isSettlement ? toUIString(yourExpense?.amount) : 0n, + youPaid && !isSettlement ? parseToCleanString(yourExpense?.amount) : 0n, + !youPaid && !isSettlement ? parseToCleanString(yourExpense?.amount) : 0n, + isSettlement ? parseToCleanString(yourExpense?.amount) : 0n, ]; }); diff --git a/src/components/Friend/FriendBalance.tsx b/src/components/Friend/FriendBalance.tsx index fb37be92..747e441a 100644 --- a/src/components/Friend/FriendBalance.tsx +++ b/src/components/Friend/FriendBalance.tsx @@ -1,13 +1,12 @@ import { type Balance, type User } from '@prisma/client'; import { clsx } from 'clsx'; -import { toUIString } from '~/utils/numbers'; - -import { useTranslation } from 'next-i18next'; import { EntityAvatar } from '../ui/avatar'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; export const FriendBalance: React.FC<{ user: User; balance: Balance }> = ({ user, balance }) => { - const { t } = useTranslation(); + const { t, getCurrencyHelpersCached } = useTranslationWithUtils(); + const { toUIString } = getCurrencyHelpersCached(balance.currency); const isPositive = 0 < balance.amount; return ( @@ -23,7 +22,7 @@ export const FriendBalance: React.FC<{ user: User; balance: Balance }> = ({ user {t('ui.actors.you')} {isPositive ? t('ui.expense.you.lent') : t('ui.expense.you.owe')}
- {balance.currency} {toUIString(balance.amount)} + {toUIString(balance.amount)}
diff --git a/src/components/Friend/GroupSettleup.tsx b/src/components/Friend/GroupSettleup.tsx index 68d613a3..aae97a32 100644 --- a/src/components/Friend/GroupSettleup.tsx +++ b/src/components/Friend/GroupSettleup.tsx @@ -1,16 +1,15 @@ import { SplitType, type User } from '@prisma/client'; import { ArrowRightIcon } from 'lucide-react'; -import React, { type ReactNode, useCallback, useState } from 'react'; +import React, { type ReactNode, useState } from 'react'; import { toast } from 'sonner'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { DEFAULT_CATEGORY } from '~/lib/category'; import { api } from '~/utils/api'; -import { BigMath, toSafeBigInt } from '~/utils/numbers'; +import { BigMath } from '~/utils/numbers'; import { EntityAvatar } from '../ui/avatar'; -import { Button } from '../ui/button'; -import { AppDrawer, DrawerClose } from '../ui/drawer'; -import { Input } from '../ui/input'; +import { CurrencyInput } from '../ui/currency-input'; +import { AppDrawer } from '../ui/drawer'; export const GroupSettleUp: React.FC<{ amount: bigint; @@ -19,14 +18,22 @@ export const GroupSettleUp: React.FC<{ user: User; children: ReactNode; groupId: number; -}> = ({ amount, currency, friend, user, children, groupId }) => { - const { displayName, t } = useTranslationWithUtils(); - const [amountStr, setAmountStr] = useState((Number(BigMath.abs(amount)) / 100).toString()); +}> = ({ amount: _amount, currency, friend, user, children, groupId }) => { + const { displayName, t, getCurrencyHelpersCached } = useTranslationWithUtils(); + const [amount, setAmount] = useState(BigMath.abs(_amount)); + const [amountStr, setAmountStr] = useState(getCurrencyHelpersCached(currency).toUIString(amount)); - const onChangeAmount = useCallback((e: React.ChangeEvent) => { - const { value } = e.target; - setAmountStr(value); - }, []); + const onCurrencyInputValueChange = React.useCallback( + ({ strValue, bigIntValue }: { strValue?: string; bigIntValue?: bigint }) => { + if (strValue !== undefined) { + setAmountStr(strValue); + } + if (bigIntValue !== undefined) { + setAmount(bigIntValue); + } + }, + [], + ); const addExpenseMutation = api.expense.addOrEditExpense.useMutation(); const utils = api.useUtils(); @@ -34,8 +41,8 @@ export const GroupSettleUp: React.FC<{ const sender = 0 > amount ? user : friend; const receiver = 0 > amount ? friend : user; - const saveExpense = useCallback(() => { - if (0n === toSafeBigInt(amountStr)) { + const saveExpense = React.useCallback(() => { + if (!amount) { return; } @@ -43,17 +50,17 @@ export const GroupSettleUp: React.FC<{ { name: t('ui.settle_up_name'), currency: currency, - amount: toSafeBigInt(amountStr), + amount, splitType: SplitType.SETTLEMENT, groupId, participants: [ { userId: sender.id, - amount: toSafeBigInt(amountStr), + amount, }, { userId: receiver.id, - amount: -toSafeBigInt(amountStr), + amount: -amount, }, ], paidBy: sender.id, @@ -69,35 +76,19 @@ export const GroupSettleUp: React.FC<{ }, }, ); - }, [sender, receiver, amountStr, utils, addExpenseMutation, currency, groupId, t]); + }, [sender, receiver, amount, utils, addExpenseMutation, currency, groupId, t]); return ( -
- - - -
{t('ui.settlement')}
- - - -
@@ -109,23 +100,13 @@ export const GroupSettleUp: React.FC<{ {displayName(sender)} {t('ui.expense.user.pay')} {displayName(receiver)}

-
-

{currency}

- -
-
-
- - - +
); diff --git a/src/components/Friend/Settleup.tsx b/src/components/Friend/Settleup.tsx index f7990209..937be7b1 100644 --- a/src/components/Friend/Settleup.tsx +++ b/src/components/Friend/Settleup.tsx @@ -1,25 +1,27 @@ import { type Balance, SplitType, type User } from '@prisma/client'; -import { ArrowRightIcon, HandCoins } from 'lucide-react'; +import { ArrowRightIcon } from 'lucide-react'; import React, { useState } from 'react'; import { toast } from 'sonner'; import { DEFAULT_CATEGORY } from '~/lib/category'; import { api } from '~/utils/api'; -import { BigMath, toSafeBigInt, toUIString } from '~/utils/numbers'; +import { BigMath } from '~/utils/numbers'; -import { FriendBalance } from './FriendBalance'; -import { Button } from '../ui/button'; -import { AppDrawer, DrawerClose } from '../ui/drawer'; -import { Input } from '../ui/input'; -import { EntityAvatar } from '../ui/avatar'; -import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { useSession } from 'next-auth/react'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { EntityAvatar } from '../ui/avatar'; +import { Button } from '../ui/button'; +import { CurrencyInput } from '../ui/currency-input'; +import { AppDrawer } from '../ui/drawer'; +import { FriendBalance } from './FriendBalance'; -export const SettleUp: React.FC<{ - balances?: Balance[]; - friend: User; -}> = ({ balances, friend }) => { - const { t, displayName } = useTranslationWithUtils(); +export const SettleUp: React.FC< + React.PropsWithChildren<{ + balances?: Balance[]; + friend: User; + }> +> = ({ children, balances, friend }) => { + const { t, displayName, getCurrencyHelpersCached } = useTranslationWithUtils(); const { data } = useSession(); const currentUser = data?.user; @@ -38,22 +40,28 @@ export const SettleUp: React.FC<{ const [balanceToSettle, setBalanceToSettle] = useState( 1 < balances.length ? undefined : balances[0], ); - const [amount, setAmount] = useState( - 1 < balances.length ? '' : toUIString(BigMath.abs(balances[0]?.amount ?? 0n)), + const [amount, setAmount] = useState( + 1 < balances.length ? 0n : BigMath.abs(balances[0]?.amount ?? 0n), + ); + const [amountStr, setAmountStr] = useState( + getCurrencyHelpersCached(balanceToSettle?.currency ?? '').toUIString(amount), ); const isCurrentUserPaying = 0 > (balanceToSettle?.amount ?? 0); function onSelectBalance(balance: Balance) { setBalanceToSettle(balance); - setAmount(toUIString(BigMath.abs(balance.amount))); + setAmount(BigMath.abs(balance.amount)); + setAmountStr( + getCurrencyHelpersCached(balance.currency).toUIString(BigMath.abs(balance.amount)), + ); } const addExpenseMutation = api.expense.addOrEditExpense.useMutation(); const utils = api.useUtils(); - function saveExpense() { - if (!balanceToSettle || !amount || !parseFloat(amount) || !currentUser) { + const saveExpense = React.useCallback(() => { + if (!balanceToSettle || !amount || !currentUser) { return; } @@ -61,16 +69,16 @@ export const SettleUp: React.FC<{ { name: t('ui.settle_up_name'), currency: balanceToSettle.currency, - amount: toSafeBigInt(amount), + amount, splitType: SplitType.SETTLEMENT, participants: [ { userId: currentUser.id, - amount: isCurrentUserPaying ? toSafeBigInt(amount) : -toSafeBigInt(amount), + amount: isCurrentUserPaying ? amount : -amount, }, { userId: friend.id, - amount: isCurrentUserPaying ? -toSafeBigInt(amount) : toSafeBigInt(amount), + amount: isCurrentUserPaying ? -amount : amount, }, ], paidBy: isCurrentUserPaying ? currentUser.id : friend.id, @@ -87,70 +95,49 @@ export const SettleUp: React.FC<{ }, }, ); - } + }, [ + balanceToSettle, + amount, + currentUser, + isCurrentUserPaying, + friend, + addExpenseMutation, + utils, + t, + ]); + + const onCurrencyInputValueChange = React.useCallback( + ({ strValue, bigIntValue }: { strValue?: string; bigIntValue?: bigint }) => { + if (strValue !== undefined) { + setAmountStr(strValue); + } + if (bigIntValue !== undefined) { + setAmount(bigIntValue); + } + }, + [], + ); + + const onBackClick = React.useCallback(() => { + if (balanceToSettle) { + setBalanceToSettle(undefined); + } + }, [balanceToSettle]); return ( - {t('ui.actions.settle_up')} - - } + trigger={children} disableTrigger={!balances?.length} - leftAction={''} - leftActionOnClick={() => { - setBalanceToSettle(undefined); - }} - title="" + leftAction={t('ui.actions.back')} + leftActionOnClick={onBackClick} + shouldCloseOnLeftAction={false} + title={balanceToSettle ? t('ui.settle_up_name') : t('ui.select_currency')} className="h-[70vh]" - actionTitle="" + actionTitle={t('ui.actions.save')} + actionDisabled={!balanceToSettle || !amount} + actionOnClick={saveExpense} shouldCloseOnAction > -
-
- {balanceToSettle && - (1 < balances.length ? ( - - ) : ( - - - - ))} -
-
- {balanceToSettle ? t('ui.settle_up_name') : t('ui.select_currency')} -
- {balanceToSettle && ( - - - - )} -
{!balanceToSettle ? (
{balances?.map((b) => ( @@ -177,45 +164,15 @@ export const SettleUp: React.FC<{ : `${displayName(friend)} ${t('ui.expense.user.pay')} ${t('ui.actors.you')}`}

-
-

{balanceToSettle.currency}

- setAmount(e.target.value)} - /> -
+
)} -
-
- {balanceToSettle && - (1 < balances.length ? ( - - ) : ( - - - - ))} -
- {balanceToSettle && ( - - - - )} -
); }; diff --git a/src/components/group/GroupMyBalance.tsx b/src/components/group/GroupMyBalance.tsx index cb35cd62..ed562e11 100644 --- a/src/components/group/GroupMyBalance.tsx +++ b/src/components/group/GroupMyBalance.tsx @@ -1,8 +1,8 @@ import { type GroupBalance, type User } from '@prisma/client'; import React from 'react'; -import { useTranslation } from 'next-i18next'; -import { BigMath, toUIString } from '~/utils/numbers'; +import { BigMath } from '~/utils/numbers'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; interface GroupMyBalanceProps { userId: number; @@ -15,7 +15,7 @@ const GroupMyBalance: React.FC = ({ groupBalances = [], users = [], }) => { - const { t } = useTranslation(); + const { t, getCurrencyHelpersCached } = useTranslationWithUtils(); const userMap = users.reduce( (acc, user) => { acc[user.id] = user; @@ -58,7 +58,7 @@ const GroupMyBalance: React.FC = ({ {youLent.map(([currency, amount], index, arr) => (
- {currency} {toUIString(amount)} + {getCurrencyHelpersCached(currency).toUIString(amount)}
{index < arr.length - 1 ? + : null}
@@ -72,7 +72,7 @@ const GroupMyBalance: React.FC = ({ {youOwe.map(([currency, amount], index, arr) => (
- {currency} {toUIString(amount)} + {getCurrencyHelpersCached(currency).toUIString(amount)}
{index < arr.length - 1 ? + : null}
@@ -95,7 +95,7 @@ const GroupMyBalance: React.FC = ({ {0 < amount ? `${friend?.name} ${t('ui.expense.user.owe')} ${t('ui.actors.you_dativus').toLowerCase()}` : `${t('ui.actors.you')} ${t('ui.expense.you.owe')} ${friend?.name}`}{' '} - {toUIString(amount)} {currency} + {getCurrencyHelpersCached(currency).toUIString(amount)} ))} diff --git a/src/components/ui/currency-input.tsx b/src/components/ui/currency-input.tsx new file mode 100644 index 00000000..95c8f33c --- /dev/null +++ b/src/components/ui/currency-input.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { Input, InputProps } from './input'; +import { cn } from '~/lib/utils'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; + +const CurrencyInput = React.forwardRef< + HTMLInputElement, + Omit & { + currency: string; + bigIntValue: bigint; + strValue: string; + onValueChange: (v: { strValue?: string; bigIntValue?: bigint }) => void; + allowNegative?: boolean; + hideSymbol?: boolean; + } +>( + ( + { + className, + currency, + bigIntValue, + allowNegative, + strValue, + onValueChange, + hideSymbol, + ...props + }, + ref, + ) => { + const { getCurrencyHelpersCached } = useTranslationWithUtils(undefined); + const { format, parseToCleanString, toSafeBigInt, sanitizeInput, stripCurrencySymbol } = + getCurrencyHelpersCached(currency); + + return ( + onValueChange({ strValue: parseToCleanString(strValue) })} + onBlur={() => { + const formattedValue = format(strValue); + return onValueChange({ + strValue: hideSymbol ? stripCurrencySymbol(formattedValue) : formattedValue, + }); + }} + onChange={(e) => { + const rawValue = e.target.value; + const strValue = sanitizeInput(rawValue, allowNegative); + const bigIntValue = toSafeBigInt(strValue); + onValueChange({ strValue, bigIntValue }); + }} + {...props} + /> + ); + }, +); +CurrencyInput.displayName = 'CurrencyInput'; + +export { CurrencyInput }; diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx index b7d24a08..416ca4ab 100644 --- a/src/components/ui/drawer.tsx +++ b/src/components/ui/drawer.tsx @@ -223,7 +223,7 @@ export const AppDrawer: React.FC = (props) => { size="sm" className="w-[100px]" onClick={leftActionOnClick} - asChild + asChild={shouldCloseOnLeftAction ?? shouldCloseOnLeftAction === undefined} > {(shouldCloseOnLeftAction ?? shouldCloseOnLeftAction === undefined) ? ( {leftAction} @@ -276,7 +276,7 @@ export const AppDrawer: React.FC = (props) => { variant="ghost" className="text-primary px-0 text-left" onClick={leftActionOnClick} - asChild + asChild={shouldCloseOnLeftAction ?? shouldCloseOnLeftAction === undefined} > {(shouldCloseOnLeftAction ?? shouldCloseOnLeftAction === undefined) ? ( {leftAction} diff --git a/src/contexts/CurrencyHelpersContext.tsx b/src/contexts/CurrencyHelpersContext.tsx new file mode 100644 index 00000000..40d33751 --- /dev/null +++ b/src/contexts/CurrencyHelpersContext.tsx @@ -0,0 +1,56 @@ +'use client'; + +import React, { createContext, useCallback, useMemo } from 'react'; +import { getCurrencyHelpers } from '~/utils/numbers'; + +export type CurrencyHelpersType = ReturnType; + +interface CurrencyHelpersContextType { + getCachedCurrencyHelpers: (currency: string, locale?: string) => CurrencyHelpersType; +} + +export const CurrencyHelpersContext = createContext( + undefined, +); + +interface CurrencyHelpersProviderProps { + children: React.ReactNode; +} + +/** + * Provider component that caches currency helpers to avoid recreating expensive Intl.NumberFormat instances. + * The cache is indexed by currency code and locale combination. + */ +export const CurrencyHelpersProvider: React.FC = ({ children }) => { + // Cache map: key is "currency|locale" + const cache = useMemo(() => new Map(), []); + + const getCachedCurrencyHelpers = useCallback( + (currency: string, locale = 'en-US') => { + const cacheKey = `${currency}|${locale}`; + + // Return cached helpers if available + if (cache.has(cacheKey)) { + return cache.get(cacheKey)!; + } + + // Create new helpers and cache them + const helpers = getCurrencyHelpers({ currency, locale }); + cache.set(cacheKey, helpers); + + return helpers; + }, + [cache], + ); + + const value: CurrencyHelpersContextType = useMemo( + () => ({ + getCachedCurrencyHelpers, + }), + [getCachedCurrencyHelpers], + ); + + return ( + {children} + ); +}; diff --git a/src/hooks/useTranslationWithUtils.ts b/src/hooks/useTranslationWithUtils.ts index 8b0a07b5..b2d8b30e 100644 --- a/src/hooks/useTranslationWithUtils.ts +++ b/src/hooks/useTranslationWithUtils.ts @@ -1,5 +1,9 @@ import { useTranslation } from 'next-i18next'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useContext, useMemo } from 'react'; +import { + CurrencyHelpersContext, + type CurrencyHelpersType, +} from '~/contexts/CurrencyHelpersContext'; import { type ParametersExceptTranslation, displayName as dn, @@ -9,13 +13,17 @@ import { } from '~/utils/strings'; export const useTranslationWithUtils = ( - namespaces?: string[], + namespaces?: string[] | string, ): ReturnType & { displayName: typeof displayName; generateSplitDescription: typeof generateSplitDescription; toUIDate: typeof toUIDate; getCurrencyName: typeof getCurrencyName; + getCurrencyHelpersCached: (currency: string) => CurrencyHelpersType; } => { + if (typeof namespaces === 'string') { + namespaces = [namespaces]; + } if (!namespaces || namespaces.length === 0) { namespaces = ['common']; } else if (!namespaces.includes('common')) { @@ -43,9 +51,35 @@ export const useTranslationWithUtils = ( [translation.t], ); + const context = useContext(CurrencyHelpersContext); + + const getCurrencyHelpersCached = useCallback( + (currency: string) => { + if (!context) { + throw new Error('useCurrencyHelpers must be used within a CurrencyHelpersProvider'); + } + return context.getCachedCurrencyHelpers(currency, translation.i18n.language); + }, + [context, translation.i18n.language], + ); + // @ts-ignore return useMemo( - () => ({ ...translation, displayName, generateSplitDescription, toUIDate, getCurrencyName }), - [translation, displayName, generateSplitDescription, toUIDate, getCurrencyName], + () => ({ + ...translation, + displayName, + generateSplitDescription, + toUIDate, + getCurrencyName, + getCurrencyHelpersCached, + }), + [ + translation, + displayName, + generateSplitDescription, + toUIDate, + getCurrencyName, + getCurrencyHelpersCached, + ], ); }; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 453c0580..41eb0136 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -10,6 +10,7 @@ import { Toaster } from 'sonner'; import { appWithTranslation, useTranslation } from 'next-i18next'; import i18nConfig from 'next-i18next.config.js'; import { ThemeProvider } from '~/components/ui/theme-provider'; +import { CurrencyHelpersProvider } from '~/contexts/CurrencyHelpersContext'; import '~/styles/globals.css'; import { LoadingSpinner } from '~/components/ui/spinner'; import { env } from '~/env'; @@ -79,14 +80,16 @@ const MyApp: AppType<{ session: Session | null; baseUrl: string }> = ({ - - - {(Component as NextPageWithUser).auth ? ( - - ) : ( - - )}{' '} - + + + + {(Component as NextPageWithUser).auth ? ( + + ) : ( + + )}{' '} + + ); diff --git a/src/pages/activity.tsx b/src/pages/activity.tsx index 97a9c38c..c86d92cd 100644 --- a/src/pages/activity.tsx +++ b/src/pages/activity.tsx @@ -6,7 +6,7 @@ import MainLayout from '~/components/Layout/MainLayout'; import { EntityAvatar } from '~/components/ui/avatar'; import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; -import { BigMath, toUIString } from '~/utils/numbers'; +import { BigMath, getCurrencyHelpers } from '~/utils/numbers'; import { type TFunction } from 'next-i18next'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { withI18nStaticProps } from '~/utils/i18n/server'; @@ -19,6 +19,7 @@ function getPaymentString( isSettlement: boolean, currency: string, t: TFunction, + toUIString: (value: bigint) => string, isDeleted?: boolean, ) { if (isDeleted) { @@ -45,7 +46,7 @@ function getPaymentString( } const ActivityPage: NextPageWithUser = ({ user }) => { - const { displayName, t, toUIDate } = useTranslationWithUtils(); + const { displayName, t, toUIDate, i18n } = useTranslationWithUtils(); const expensesQuery = api.expense.getAllExpenses.useQuery(); return ( @@ -59,49 +60,57 @@ const ActivityPage: NextPageWithUser = ({ user }) => { {!expensesQuery.data?.length ? (
{t('ui.no_activity')}
) : null} - {expensesQuery.data?.map((e) => ( - -
- -
-
- {e.expense.deletedByUser ? ( -

- - {displayName(e.expense.deletedByUser, user.id)} - {' '} - {t( - `ui.expense.${e.expense.deletedByUser.id === user.id ? 'you' : 'user'}.deleted`, - )}{' '} - {e.expense.name} -

- ) : ( -

- - {displayName(e.expense.paidByUser, user.id)} - {' '} - {t(`ui.expense.${e.expense.paidByUser.id === user.id ? 'you' : 'user'}.paid`)}{' '} - {t('ui.expense.for')}{' '} - {e.expense.name} -

- )} + {expensesQuery.data?.map((e) => { + const { toUIString } = getCurrencyHelpers({ + locale: i18n.language, + currency: e.expense.currency, + }); + return ( + +
+ +
- {getPaymentString( - user, - e.expense.amount, - e.expense.paidBy, - e.amount, - e.expense.splitType === SplitType.SETTLEMENT, - e.expense.currency, - t, - !!e.expense.deletedBy, + {e.expense.deletedByUser ? ( +

+ + {displayName(e.expense.deletedByUser, user.id)} + {' '} + {t( + `ui.expense.${e.expense.deletedByUser.id === user.id ? 'you' : 'user'}.deleted`, + )}{' '} + {e.expense.name} +

+ ) : ( +

+ + {displayName(e.expense.paidByUser, user.id)} + {' '} + {t(`ui.expense.${e.expense.paidByUser.id === user.id ? 'you' : 'user'}.paid`)}{' '} + {toUIString(e.expense.amount)} {t('ui.expense.for')}{' '} + {e.expense.name} +

)} + +
+ {getPaymentString( + user, + e.expense.amount, + e.expense.paidBy, + e.amount, + e.expense.splitType === SplitType.SETTLEMENT, + e.expense.currency, + t, + toUIString, + !!e.expense.deletedBy, + )} +
+

{toUIDate(e.expense.expenseDate)}

-

{toUIDate(e.expense.expenseDate)}

-
- - ))} + + ); + })} diff --git a/src/pages/add.tsx b/src/pages/add.tsx index ef3319dd..777af650 100644 --- a/src/pages/add.tsx +++ b/src/pages/add.tsx @@ -1,7 +1,6 @@ import Head from 'next/head'; import { useRouter } from 'next/router'; import { useEffect } from 'react'; -import { useTranslation } from 'next-i18next'; import { AddOrEditExpensePage } from '~/components/AddExpense/AddExpensePage'; import MainLayout from '~/components/Layout/MainLayout'; import { env } from '~/env'; @@ -12,12 +11,13 @@ import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; import { customServerSideTranslations } from '~/utils/i18n/server'; import { type GetServerSideProps } from 'next'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; const AddPage: NextPageWithUser<{ isStorageConfigured: boolean; enableSendingInvites: boolean; }> = ({ user, isStorageConfigured, enableSendingInvites }) => { - const { t } = useTranslation('add_page'); + const { t, getCurrencyHelpersCached } = useTranslationWithUtils('add_page'); const { setCurrentUser, setGroup, @@ -101,7 +101,9 @@ const AddPage: NextPageWithUser<{ } setPaidBy(expenseQuery.data.paidByUser); setCurrency(parseCurrencyCode(expenseQuery.data.currency)); - setAmountStr((Number(expenseQuery.data.amount) / 100).toString()); + setAmountStr( + getCurrencyHelpersCached(expenseQuery.data.currency).toUIString(expenseQuery.data.amount), + ); setDescription(expenseQuery.data.name); setCategory(expenseQuery.data.category); setAmount(expenseQuery.data.amount); diff --git a/src/pages/balances.tsx b/src/pages/balances.tsx index e648ff2d..a36adef2 100644 --- a/src/pages/balances.tsx +++ b/src/pages/balances.tsx @@ -1,6 +1,5 @@ import { ArrowUpOnSquareIcon } from '@heroicons/react/24/outline'; import { Download, PlusIcon } from 'lucide-react'; -import { useTranslation } from 'next-i18next'; import Head from 'next/head'; import Link from 'next/link'; import { useCallback } from 'react'; @@ -10,13 +9,13 @@ import MainLayout from '~/components/Layout/MainLayout'; import { NotificationModal } from '~/components/NotificationModal'; import { Button } from '~/components/ui/button'; import { useIsPwa } from '~/hooks/useIsPwa'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; import { withI18nStaticProps } from '~/utils/i18n/server'; -import { toUIString } from '~/utils/numbers'; const BalancePage: NextPageWithUser = () => { - const { t } = useTranslation(); + const { t, getCurrencyHelpersCached } = useTranslationWithUtils(); const isPwa = useIsPwa(); const balanceQuery = api.expense.getBalances.useQuery(); @@ -69,7 +68,7 @@ const BalancePage: NextPageWithUser = () => { {balanceQuery.data?.youOwe.map((balance, index) => ( - {balance.currency.toUpperCase()} {toUIString(balance.amount)} + {getCurrencyHelpersCached(balance.currency).toUIString(balance.amount)} {index !== balanceQuery.data.youOwe.length - 1 ? ( + @@ -92,7 +91,7 @@ const BalancePage: NextPageWithUser = () => { {balanceQuery.data?.youGet.map((balance, index) => (

- {balance.currency.toUpperCase()} {toUIString(balance.amount)} + {getCurrencyHelpersCached(balance.currency).toUIString(balance.amount)}

{' '} {index !== balanceQuery.data.youGet.length - 1 ? ( + diff --git a/src/pages/balances/[friendId].tsx b/src/pages/balances/[friendId].tsx index 8acfb888..e1953001 100644 --- a/src/pages/balances/[friendId].tsx +++ b/src/pages/balances/[friendId].tsx @@ -1,4 +1,4 @@ -import { ChevronLeftIcon, PlusIcon } from 'lucide-react'; +import { ChevronLeftIcon, HandCoins, PlusIcon } from 'lucide-react'; import Head from 'next/head'; import Link from 'next/link'; import { useRouter } from 'next/router'; @@ -12,13 +12,12 @@ import { Button } from '~/components/ui/button'; import { Separator } from '~/components/ui/separator'; import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; -import { toUIString } from '~/utils/numbers'; import { customServerSideTranslations } from '~/utils/i18n/server'; import { type GetServerSideProps } from 'next'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; const FriendPage: NextPageWithUser = ({ user }) => { - const { t, displayName } = useTranslationWithUtils(); + const { t, displayName, getCurrencyHelpersCached } = useTranslationWithUtils(); const router = useRouter(); const { friendId } = router.query; @@ -86,7 +85,7 @@ const FriendPage: NextPageWithUser = ({ user }) => { {youOwe?.map((bal, index) => ( - {bal.currency} {toUIString(bal.amount)} + {getCurrencyHelpersCached(bal.currency).toUIString(bal.amount)} {youOwe.length - 1 === index ? '' : ' + '} @@ -102,7 +101,7 @@ const FriendPage: NextPageWithUser = ({ user }) => { {youLent?.map((bal, index) => ( - {bal.currency} {toUIString(bal.amount)} + {getCurrencyHelpersCached(bal.currency).toUIString(bal.amount)} {youLent.length - 1 === index ? '' : ' + '} @@ -112,7 +111,15 @@ const FriendPage: NextPageWithUser = ({ user }) => {
- + + + + +

{t('ui.and', { ns: 'common' })}

+ + + +
diff --git a/src/components/AddExpense/SplitTypeSection.tsx b/src/components/AddExpense/SplitTypeSection.tsx index 78975530..8f89882e 100644 --- a/src/components/AddExpense/SplitTypeSection.tsx +++ b/src/components/AddExpense/SplitTypeSection.tsx @@ -10,10 +10,10 @@ import { Plus, X, } from 'lucide-react'; -import React, { type ChangeEvent, useCallback, useMemo } from 'react'; +import React, { type ChangeEvent, type PropsWithChildren, useCallback, useMemo } from 'react'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; -import { type AddExpenseState, type Participant, useAddExpenseStore } from '~/store/addStore'; +import { type Participant, useAddExpenseStore } from '~/store/addStore'; import { removeTrailingZeros } from '~/utils/numbers'; import { type TFunction, useTranslation } from 'next-i18next'; @@ -25,65 +25,24 @@ import { AppDrawer, DrawerClose } from '../ui/drawer'; import { Input } from '../ui/input'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; -export const SplitTypeSection: React.FC = () => { - const { t, displayName, generateSplitDescription } = useTranslationWithUtils(['expense_details']); - const isNegative = useAddExpenseStore((s) => s.isNegative); +export const PayerSelectionForm: React.FC = ({ children }) => { + const { t } = useTranslationWithUtils('expense_details'); const paidBy = useAddExpenseStore((s) => s.paidBy); const participants = useAddExpenseStore((s) => s.participants); - const currentUser = useAddExpenseStore((s) => s.currentUser); - const canSplitScreenClosed = useAddExpenseStore((s) => s.canSplitScreenClosed); - const splitType = useAddExpenseStore((s) => s.splitType); - const splitScreenOpen = useAddExpenseStore((s) => s.splitScreenOpen); - const splitShares = useAddExpenseStore((s) => s.splitShares); - - const { setSplitScreenOpen } = useAddExpenseStore((s) => s.actions); return ( -
-

- {t(`ui.expense.${isNegative ? 'received_by' : 'paid_by'}`, { ns: 'common' })}{' '} -

- - {displayName(paidBy, currentUser?.id, 'dativus')} -

- } - title={t('ui.expense.paid_by', { ns: 'common' })} - className="h-[70vh]" - shouldCloseOnAction - > -
- {participants.map((participant) => ( - - ))} -
-
-

{t('ui.and', { ns: 'common' })}

- - {generateSplitDescription(splitType, participants, splitShares, paidBy, currentUser)} -
- } - title={t( - `ui.add_expense_details.split_type_section.types.${splitType.toLowerCase()}.title`, - )} - className="h-[85vh] lg:h-[70vh]" - shouldCloseOnAction - dismissible={canSplitScreenClosed} - actionTitle={t('ui.actions.save', { ns: 'common' })} - actionDisabled={!canSplitScreenClosed} - open={splitScreenOpen} - onOpenChange={setSplitScreenOpen} - > - - -
+ +
+ {participants.map((participant) => ( + + ))} +
+
); }; @@ -105,10 +64,14 @@ const PayerRow = ({ p, isPaying }: { p: Participant; isPaying: boolean }) => { ); }; -const SplitExpenseForm: React.FC = () => { +export const SplitExpenseForm: React.FC = ({ children }) => { const { t } = useTranslation('expense_details'); const splitType = useAddExpenseStore((s) => s.splitType); const { setSplitType } = useAddExpenseStore((s) => s.actions); + const canSplitScreenClosed = useAddExpenseStore((s) => s.canSplitScreenClosed); + const splitScreenOpen = useAddExpenseStore((s) => s.splitScreenOpen); + + const { setSplitScreenOpen } = useAddExpenseStore((s) => s.actions); const onTabChange = useCallback( (value: string) => { @@ -120,20 +83,32 @@ const SplitExpenseForm: React.FC = () => { const splitProps = useMemo(() => getSplitProps(t), [t]); return ( - - - {splitProps.map(({ splitType, iconComponent: Icon }) => ( - - - + + + + {splitProps.map(({ splitType, iconComponent: Icon }) => ( + + + + ))} + + {splitProps.map((props) => ( + + + ))} - - {splitProps.map((props) => ( - - - - ))} - + + ); }; diff --git a/src/components/AddExpense/UserInput.tsx b/src/components/AddExpense/UserInput.tsx index 78ebb10a..95750b8b 100644 --- a/src/components/AddExpense/UserInput.tsx +++ b/src/components/AddExpense/UserInput.tsx @@ -68,7 +68,7 @@ export const UserInput: React.FC<{ }; return ( -
+
{group ? (
From 7afdc25f3feaaeee086a5fba263474c6f3a79c74 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 19 Oct 2025 11:17:28 +0200 Subject: [PATCH 11/14] Negative number handling fix --- src/tests/number.test.ts | 12 ++++++------ src/utils/numbers.ts | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/tests/number.test.ts b/src/tests/number.test.ts index 6c482aab..72770cb4 100644 --- a/src/tests/number.test.ts +++ b/src/tests/number.test.ts @@ -12,8 +12,8 @@ describe('getCurrencyHelpers', () => { it.each([ [12345n, '$123.45'], - [-12345n, '$123.45'], - [-50n, '$0.5'], + [-12345n, '-$123.45'], + [-50n, '-$0.5'], [-0n, '$0'], [99999999999999999999999999999n, '$999,999,999,999,999,999,999,999,999.99'], ])('should format %p as %p ', (value, expected) => { @@ -37,8 +37,8 @@ describe('getCurrencyHelpers', () => { it.each([ [12345n, '¥12,345'], - [-12345n, '¥12,345'], - [-50n, '¥50'], + [-12345n, '-¥12,345'], + [-50n, '-¥50'], [-0n, '¥0'], ])('should format %p as %p ', (value, expected) => { expect(toUIString(value)).toBe(expected); @@ -56,8 +56,8 @@ describe('getCurrencyHelpers', () => { it.each([ [12345n, '123,45 €'], - [-12345n, '123,45 €'], - [-50n, '0,5 €'], + [-12345n, '-123,45 €'], + [-50n, '-0,5 €'], [-0n, '0 €'], ])('should format %p as %p ', (value, expected) => { expect(toUIString(value)).toBe(expected); diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts index ba8978cb..0188b002 100644 --- a/src/utils/numbers.ts +++ b/src/utils/numbers.ts @@ -116,7 +116,7 @@ export const getCurrencyHelpers = ({ }; const normalizeToMaxLength = (inputString: string) => { - const sanitized = sanitizeInput(inputString); + const sanitized = sanitizeInput(inputString, true); const trimmedExceedingDecimals = trimExceedingDecimals(sanitized); return trimmedExceedingDecimals.endsWith(decimalSeparator) ? trimmedExceedingDecimals.slice(0, -1) @@ -134,10 +134,14 @@ export const getCurrencyHelpers = ({ } if (typeof value === 'bigint') { + const sign = value < 0n ? '-' : ''; const integer = `${value / decimalMultiplierN}`; const fraction = `${value}`.slice(-decimalDigits); - return normalizeToMaxLength( - decimalDigits > 0 ? `${integer}${decimalSeparator}${fraction}` : integer, + return ( + sign + + normalizeToMaxLength( + decimalDigits > 0 ? `${integer}${decimalSeparator}${fraction}` : integer, + ) ); } @@ -166,11 +170,13 @@ export const getCurrencyHelpers = ({ return formatter.format(0); } + const sign = value.startsWith('-') ? '-' : ''; const normalizedToMaxLength = normalizeToMaxLength(value); const bigintValue = parseToBigIntBeforeSubmit(normalizedToMaxLength); - const parts = formatter.formatToParts(bigintValue / decimalMultiplierN); + const parts = formatter.formatToParts(BigMath.abs(bigintValue) / decimalMultiplierN); const auxParts = formatter.formatToParts( - Number(BigMath.abs(bigintValue) % decimalMultiplierN) / decimalMultiplier, + (Number(BigMath.abs(bigintValue) % decimalMultiplierN) / decimalMultiplier) * + Number(BigMath.sign(bigintValue)), ); const fractionPart = auxParts.find(({ type }) => type === 'fraction'); const decimalPart = auxParts.find(({ type }) => type === 'decimal'); @@ -192,7 +198,7 @@ export const getCurrencyHelpers = ({ } } - return parts.map(({ value }) => value).join(''); + return sign + parts.map(({ value }) => value).join(''); }; const toUIString = (value: unknown) => { From b54471d9d25f908ce84a7b5537caab85af5893e2 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 19 Oct 2025 11:19:43 +0200 Subject: [PATCH 12/14] Responsive invite buttons --- .../AddExpense/SelectUserOrGroup.tsx | 6 ++--- src/tests/number.test.ts | 24 ++++++++++++++----- src/utils/numbers.ts | 17 ++++++------- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/components/AddExpense/SelectUserOrGroup.tsx b/src/components/AddExpense/SelectUserOrGroup.tsx index bc109580..531fee67 100644 --- a/src/components/AddExpense/SelectUserOrGroup.tsx +++ b/src/components/AddExpense/SelectUserOrGroup.tsx @@ -93,10 +93,10 @@ export const SelectUserOrGroup: React.FC<{
{t('ui.add_expense_details.select_user_or_group.note')}
)}
-
+
{enableSendingInvites && ( )} -
+ )} {participants.map((p) => ( s.canSplitScreenClosed); const paidBy = useAddExpenseStore((s) => s.paidBy); const amount = useAddExpenseStore((s) => s.amount); + const currentUser = useAddExpenseStore((s) => s.currentUser); - const { getCurrencyHelpersCached } = useTranslationWithUtils('expense_details'); + const { getCurrencyHelpersCached, displayName } = useTranslationWithUtils('expense_details'); const { toUIString } = getCurrencyHelpersCached(currency); const shareAmount = paidBy?.id === user.id ? (user.amount ?? 0n) - amount : user.amount; return ( -
+
-

{user.name ?? user.email}

-

- {0n < (shareAmount ?? 0n) ? '-' : ''} {currency} {toUIString(shareAmount)} +

{displayName(user, currentUser?.id)}

+

+ {0n < (shareAmount ?? 0n) ? '-' : ''} {toUIString(shareAmount)}

From a502de0c64be2ab95d444a5ad458e2b13c05cb07 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 19 Oct 2025 11:46:38 +0200 Subject: [PATCH 14/14] More fixes for numeric inputs --- src/components/AddExpense/SplitTypeSection.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/AddExpense/SplitTypeSection.tsx b/src/components/AddExpense/SplitTypeSection.tsx index 70b2cf48..d902b927 100644 --- a/src/components/AddExpense/SplitTypeSection.tsx +++ b/src/components/AddExpense/SplitTypeSection.tsx @@ -14,7 +14,7 @@ import React, { type ChangeEvent, type PropsWithChildren, useCallback, useMemo } import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { type Participant, useAddExpenseStore } from '~/store/addStore'; -import { removeTrailingZeros } from '~/utils/numbers'; +import { BigMath } from '~/utils/numbers'; import { type TFunction, useTranslation } from 'next-i18next'; import type { CurrencyCode } from '~/lib/currency'; @@ -221,7 +221,7 @@ const SplitSection: React.FC = (props) => { const splitShares = useAddExpenseStore((s) => s.splitShares); const { setSplitShare } = useAddExpenseStore((s) => s.actions); - const { fmtSummartyText, splitType, isBoolean } = props; + const { fmtSummartyText, splitType, isBoolean, isCurrency } = props; const selectAll = useCallback(() => { participants.forEach((p) => { @@ -243,10 +243,12 @@ const SplitSection: React.FC = (props) => { setSplitShare( splitType, userId, - value === undefined || '' === value ? 0n : toSafeBigInt(value), + value === undefined || '' === value + ? 0n + : BigMath.abs(toSafeBigInt(isCurrency ? value : parseFloat(value))), ); }, - [setSplitShare, splitType, toSafeBigInt], + [setSplitShare, splitType, toSafeBigInt, isCurrency], ); return (