From ab4684e3133b10844df253da7d8842d0f6e9aea9 Mon Sep 17 00:00:00 2001 From: Marco Sinigaglia Date: Fri, 24 Oct 2025 17:53:42 +0200 Subject: [PATCH 01/16] Refactor i18n to use ICU and sentences where possible --- package.json | 2 + pnpm-lock.yaml | 66 +++++++++++++++++++++++++ public/locales/en/common.json | 27 ++++++++-- public/locales/it/common.json | 27 ++++++++-- src/components/Expense/BalanceEntry.tsx | 2 +- src/components/Expense/ExpenseList.tsx | 47 ++++++++++++------ src/components/Friend/FriendBalance.tsx | 2 +- src/components/Friend/GroupSettleup.tsx | 15 ++++-- src/components/Friend/Settleup.tsx | 4 +- src/components/group/GroupMyBalance.tsx | 21 +++++--- src/pages/_app.tsx | 3 ++ src/pages/auth/signin.tsx | 2 +- src/pages/balances.tsx | 8 +-- src/pages/balances/[friendId].tsx | 4 +- src/utils/i18n/registerICU.ts | 13 +++++ src/utils/i18n/server.ts | 3 ++ src/utils/strings.ts | 2 +- 17 files changed, 204 insertions(+), 44 deletions(-) create mode 100644 src/utils/i18n/registerICU.ts diff --git a/package.json b/package.json index d91118fd8..07c94c687 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,9 @@ "i18next": "^25.2.1", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", + "i18next-icu": "^2.4.1", "input-otp": "^1.2.3", + "intl-messageformat": "^10.7.18", "lucide-react": "^0.544.0", "nanoid": "^5.0.6", "next": "15.4.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6da579921..65eb33a05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,9 +84,15 @@ importers: i18next-http-backend: specifier: ^3.0.2 version: 3.0.2 + i18next-icu: + specifier: ^2.4.1 + version: 2.4.1(intl-messageformat@10.7.18) input-otp: specifier: ^1.2.3 version: 1.4.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + intl-messageformat: + specifier: ^10.7.18 + version: 10.7.18 lucide-react: specifier: ^0.544.0 version: 0.544.0(react@19.1.1) @@ -1242,6 +1248,21 @@ packages: '@floating-ui/utils@0.2.9': resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} + '@formatjs/ecma402-abstract@2.3.6': + resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==} + + '@formatjs/fast-memoize@2.2.7': + resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==} + + '@formatjs/icu-messageformat-parser@2.11.4': + resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==} + + '@formatjs/icu-skeleton-parser@1.8.16': + resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==} + + '@formatjs/intl-localematcher@0.6.2': + resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + '@heroicons/react@2.2.0': resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==} peerDependencies: @@ -4032,6 +4053,11 @@ packages: i18next-http-backend@3.0.2: resolution: {integrity: sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==} + i18next-icu@2.4.1: + resolution: {integrity: sha512-fh01aSjGlnsR377J7mu/CM3wcdZTpvNZejapPfHI+YnVyiWwvGFT2gZOgecm9B19ttyAJ3ijmHG6r/2jiQqXCA==} + peerDependencies: + intl-messageformat: ^10.3.3 + i18next@25.2.1: resolution: {integrity: sha512-+UoXK5wh+VlE1Zy5p6MjcvctHXAhRwQKCxiJD8noKZzIXmnAX8gdHX5fLPA3MEVxEN4vbZkQFy8N0LyD9tUqPw==} peerDependencies: @@ -4073,6 +4099,9 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + intl-messageformat@10.7.18: + resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -7408,6 +7437,32 @@ snapshots: '@floating-ui/utils@0.2.9': {} + '@formatjs/ecma402-abstract@2.3.6': + dependencies: + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/intl-localematcher': 0.6.2 + decimal.js: 10.5.0 + tslib: 2.8.1 + + '@formatjs/fast-memoize@2.2.7': + dependencies: + tslib: 2.8.1 + + '@formatjs/icu-messageformat-parser@2.11.4': + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + '@formatjs/icu-skeleton-parser': 1.8.16 + tslib: 2.8.1 + + '@formatjs/icu-skeleton-parser@1.8.16': + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.6.2': + dependencies: + tslib: 2.8.1 + '@heroicons/react@2.2.0(react@19.1.1)': dependencies: react: 19.1.1 @@ -10436,6 +10491,10 @@ snapshots: transitivePeerDependencies: - encoding + i18next-icu@2.4.1(intl-messageformat@10.7.18): + dependencies: + intl-messageformat: 10.7.18 + i18next@25.2.1(typescript@5.7.2): dependencies: '@babel/runtime': 7.27.6 @@ -10473,6 +10532,13 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + intl-messageformat@10.7.18: + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/icu-messageformat-parser': 2.11.4 + tslib: 2.8.1 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 8d2c751da..715de8c58 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -133,7 +133,7 @@ "request_error": "Error requesting notification", "saving_expense": "Error while saving expense", "setting_update_failed": "Failed to update setting", - "signin_error": "An error occurred while signing in: ", + "signin_error": "An error occurred while signing in: {{error}}", "signup_disabled": "Signup of new accounts is disabled on this instance", "something_went_wrong": "Something went wrong", "subscribe_error": "Cannot subscribe to notification", @@ -358,7 +358,6 @@ "lent": "lent", "owe": "owes", "paid": "paid", - "pay": "pays", "received": "received" }, "you": { @@ -369,6 +368,27 @@ "paid": "paid", "pay": "pay", "received": "received" + }, + "statements": { + "you_lent": "You lent", + "you_owe": "You owe", + "you_lent_amount": "You lent {{currency}} {{amount}}", + "you_owe_amount": "You owe {{currency}} {{amount}}", + "you_paid_amount": "You paid {{currency}} {{amount}}", + "you_received_amount": "You received {{currency}} {{amount}}", + "you_pay_user": "You pay {{user}}", + "user_pays_you": "{{user}} pays you", + "friend_owes_you": "{{friend}} owes {{youDative}}", + "you_owe_friend": "You owe {{friend}}", + "paid_for_beneficiary": "paid for {{beneficiary}}", + "user_paid_amount": "{{user}} paid {{currency}} {{amount}}", + "user_received_amount": "{{user}} received {{currency}} {{amount}}", + "user_paid_amount_to_user": "{{payer}} paid {{currency}} {{amount}} to {{receiver}}", + "user_received_amount_from_user": "{{payer}} received {{currency}} {{amount}} from {{receiver}}", + "you_paid_amount_to_user": "You paid {{currency}} {{amount}} to {{receiver}}", + "you_received_amount_from_user": "You received {{currency}} {{amount}} from {{payer}}", + "for_users": "for {{payer}} and {{receiver}}", + "user_pays_user": "{{payer}} pays {{receiver}}" } }, "expense_details": "Expense details", @@ -383,6 +403,7 @@ "settlement": "Settlement", "share_text": "Check out SplitPro. It's an open source free alternative for Splitwise", "today": "Today", - "in_group": "In group" + "in_group": "In group", + "remaining_balances": "+{{count}} {{count, plural, one {balance} other {balances}}}..." } } diff --git a/public/locales/it/common.json b/public/locales/it/common.json index 2ac6ac31b..5b68451fa 100644 --- a/public/locales/it/common.json +++ b/public/locales/it/common.json @@ -120,7 +120,7 @@ "request_error": "Errore durante la richiesta delle notifiche", "saving_expense": "Errore durante il salvataggio della spesa", "setting_update_failed": "Errore nell'aggiornamento dell'impostazione", - "signin_error": "Si è verificato un errore durante l'accesso: ", + "signin_error": "Si è verificato un errore durante l'accesso: {{error}}", "signup_disabled": "La registrazione di nuovi account è disabilitata in questa istanza", "something_went_wrong": "Qualcosa è andato storto", "subscribe_error": "Impossibile attivare le notifiche", @@ -308,7 +308,6 @@ "lent": "ha prestato", "owe": "deve", "paid": "ha pagato", - "pay": "paga", "received": "ha ricevuto" }, "you": { @@ -319,6 +318,27 @@ "paid": "hai pagato", "pay": "paga", "received": "hai ricevuto" + }, + "statements": { + "you_lent": "Hai prestato", + "you_owe": "Devi", + "you_lent_amount": "Hai prestato {{currency}} {{amount}}", + "you_owe_amount": "Devi {{currency}} {{amount}}", + "you_paid_amount": "Hai pagato {{currency}} {{amount}}", + "you_received_amount": "Hai ricevuto {{currency}} {{amount}}", + "you_pay_user": "Paghi {{user}}", + "user_pays_you": "{{user}} ti paga", + "friend_owes_you": "{{friend}} ti deve", + "you_owe_friend": "Devi a {{friend}}", + "paid_for_beneficiary": "pagato per {{beneficiary}}", + "user_paid_amount": "{{user}} ha pagato {{currency}} {{amount}}", + "user_received_amount": "{{user}} ha ricevuto {{currency}} {{amount}}", + "user_paid_amount_to_user": "{{payer}} ha pagato {{currency}} {{amount}} a {{receiver}}", + "user_received_amount_from_user": "{{payer}} ha ricevuto {{currency}} {{amount}} da {{receiver}}", + "you_paid_amount_to_user": "Hai pagato {{currency}} {{amount}} a {{receiver}}", + "you_received_amount_from_user": "Hai ricevuto {{currency}} {{amount}} da {{payer}}", + "for_users": "per {{payer}} e {{receiver}}", + "user_pays_user": "{{payer}} paga {{receiver}}" } }, "expense_details": "Dettagli spesa", @@ -332,6 +352,7 @@ "settled_up": "Regolato", "settlement": "Regolamento", "share_text": "Dai un'occhiata a SplitPro. È un'alternativa gratuita e open source a Splitwise", - "today": "Oggi" + "today": "Oggi", + "remaining_balances": "+{{count}} {{count, plural, one {saldo} other {saldi}}}..." } } diff --git a/src/components/Expense/BalanceEntry.tsx b/src/components/Expense/BalanceEntry.tsx index 74ee03feb..c7825fb1b 100644 --- a/src/components/Expense/BalanceEntry.tsx +++ b/src/components/Expense/BalanceEntry.tsx @@ -36,7 +36,7 @@ export const BalanceEntry: React.FC<{ isPositive ? 'text-emerald-500' : 'text-orange-600', )} > - {t('actors.you')} {t(`ui.expense.you.${isPositive ? 'lent' : 'owe'}`)} + {t(`ui.expense.statements.${isPositive ? 'you_lent' : 'you_owe'}`)}
{currency} {toUIString(amount)} diff --git a/src/components/Expense/ExpenseList.tsx b/src/components/Expense/ExpenseList.tsx index a6c18e81d..1eb581bdc 100644 --- a/src/components/Expense/ExpenseList.tsx +++ b/src/components/Expense/ExpenseList.tsx @@ -4,9 +4,9 @@ import Image from 'next/image'; import Link from 'next/link'; import React from 'react'; import { - CURRENCY_CONVERSION_ICON, CategoryIcon, - SETTLEUP_ICON, + CURRENCY_CONVERSION_ICON as CurrencyConversionIcon, + SETTLEUP_ICON as SettleUpIcon, } from '~/components/ui/categoryIcons'; import type { ExpenseRouter } from '~/server/api/routers/expense'; import { toUIString } from '~/utils/numbers'; @@ -71,6 +71,10 @@ const Expense: ExpenseComponent = ({ e, userId }) => { const yourExpenseAmount = youPaid ? (theirExpense?.amount ?? yourExpense?.amount ?? 0n) : -(yourExpense?.amount ?? 0n); + const isYouPayer = e.paidBy === userId; + const amountStatementKey = `ui.expense.statements.${isYouPayer ? 'you' : 'user'}_${ + e.amount < 0n ? 'received_amount' : 'paid_amount' + }`; return ( <> @@ -82,9 +86,11 @@ const Expense: ExpenseComponent = ({ e, userId }) => {

{e.name}

- {displayName(e.paidByUser, userId)}{' '} - {t(`ui.expense.user.${e.amount < 0n ? 'received' : 'paid'}`)} {e.currency}{' '} - {toUIString(e.amount)} + {t(amountStatementKey, { + user: displayName(e.paidByUser, userId), + currency: e.currency, + amount: toUIString(e.amount), + })}

@@ -94,7 +100,7 @@ const Expense: ExpenseComponent = ({ e, userId }) => {
- {t('actors.you')} {t(`ui.expense.you.${youPaid ? 'lent' : 'owe'}`)} + {t(`ui.expense.statements.${youPaid ? 'you_lent' : 'you_owe'}`)}
{e.currency} {toUIString(yourExpenseAmount)} @@ -115,18 +121,29 @@ const Settlement: ExpenseComponent = ({ e, userId }) => { const receiverId = e.expenseParticipants.find((p) => p.userId !== e.paidBy)?.userId; const userDetails = api.user.getUserDetails.useQuery({ userId: receiverId! }); - + const isYouPayer = e.paidBy === userId; + const isYouReceiver = receiverId === userId; + const settlementStatementKey = `ui.expense.statements.${ + isYouPayer ? 'you' : 'user' + }_${e.amount < 0n ? 'received_amount_from_user' : 'paid_amount_to_user'}`; + const receiverLabel = isYouReceiver + ? t('actors.you_dativus').toLowerCase() + : displayName(userDetails.data, userId); + const payerLabel = displayName(e.paidByUser, userId); return (
{toUIDate(e.expenseDate)}
- +

- {displayName(e.paidByUser, userId)}{' '} - {t(`ui.expense.user.${e.amount < 0n ? 'received' : 'paid'}`)} {e.currency}{' '} - {toUIString(e.amount)} {t('ui.expense.to')} {displayName(userDetails.data, userId)} + {t(settlementStatementKey, { + payer: payerLabel, + receiver: receiverLabel, + currency: e.currency, + amount: toUIString(e.amount), + })}

@@ -144,7 +161,7 @@ const CurrencyConversion: ExpenseComponent = ({ e, userId }) => {
{toUIDate(e.expenseDate)}
- +

{/* @ts-ignore */} @@ -152,8 +169,10 @@ const CurrencyConversion: ExpenseComponent = ({ e, userId }) => { {toUIString(e.conversionTo.amount)}

- {t('ui.expense.for')} {displayName(e.paidByUser, userId)} {t('ui.and')}{' '} - {displayName(userDetails.data, userId)} + {t('ui.expense.statements.for_users', { + payer: displayName(e.paidByUser, userId), + receiver: displayName(userDetails.data, userId), + })}

diff --git a/src/components/Friend/FriendBalance.tsx b/src/components/Friend/FriendBalance.tsx index 8d1467ebf..05dcac641 100644 --- a/src/components/Friend/FriendBalance.tsx +++ b/src/components/Friend/FriendBalance.tsx @@ -20,7 +20,7 @@ export const FriendBalance: React.FC<{ user: User; balance: Balance }> = ({ user
- {t('actors.you')} {isPositive ? t('ui.expense.you.lent') : t('ui.expense.you.owe')} + {t(`ui.expense.statements.${isPositive ? 'you_lent' : 'you_owe'}`)}
{balance.currency} {toUIString(balance.amount)} diff --git a/src/components/Friend/GroupSettleup.tsx b/src/components/Friend/GroupSettleup.tsx index 01d748e93..bbd0c30dd 100644 --- a/src/components/Friend/GroupSettleup.tsx +++ b/src/components/Friend/GroupSettleup.tsx @@ -33,6 +33,17 @@ export const GroupSettleUp: React.FC<{ const sender = 0 > amount ? user : friend; const receiver = 0 > amount ? friend : user; + const payerRole = sender.id === user.id ? 'you' : 'other'; + const receiverRole = receiver.id === user.id ? 'you' : 'other'; + const paymentText = + payerRole === 'you' + ? t('ui.expense.statements.you_pay_user', { user: displayName(receiver) }) + : receiverRole === 'you' + ? t('ui.expense.statements.user_pays_you', { user: displayName(sender) }) + : t('ui.expense.statements.user_pays_user', { + payer: displayName(sender), + receiver: displayName(receiver), + }); const saveExpense = useCallback(() => { if (0n === toSafeBigInt(amountStr)) { @@ -105,9 +116,7 @@ export const GroupSettleUp: React.FC<{
-

- {displayName(sender)} {t('ui.expense.user.pay')} {displayName(receiver)} -

+

{paymentText}

{currency}

diff --git a/src/components/Friend/Settleup.tsx b/src/components/Friend/Settleup.tsx index 3d49c33d5..ffd819027 100644 --- a/src/components/Friend/Settleup.tsx +++ b/src/components/Friend/Settleup.tsx @@ -173,8 +173,8 @@ export const SettleUp: React.FC<{

{isCurrentUserPaying - ? `${t('actors.you')} ${t('ui.expense.you.pay')} ${displayName(friend)}` - : `${displayName(friend)} ${t('ui.expense.user.pay')} ${t('actors.you')}`} + ? t('ui.expense.statements.you_pay_user', { user: displayName(friend) }) + : t('ui.expense.statements.user_pays_you', { user: displayName(friend) })}

diff --git a/src/components/group/GroupMyBalance.tsx b/src/components/group/GroupMyBalance.tsx index 25e8c9ece..c61e486b5 100644 --- a/src/components/group/GroupMyBalance.tsx +++ b/src/components/group/GroupMyBalance.tsx @@ -48,13 +48,14 @@ const GroupMyBalance: React.FC = ({ const youLent = Object.entries(cumulatedBalances).filter(([_, amount]) => 0 < amount); const youOwe = Object.entries(cumulatedBalances).filter(([_, amount]) => 0 > amount); + const youDative = t('actors.you_dativus').toLowerCase(); return (
{0 < youLent.length ? (
- {t('actors.you')} {t('ui.expense.you.lent')} + {t('ui.expense.statements.you_lent')} {youLent.map(([currency, amount], index, arr) => (
@@ -68,7 +69,7 @@ const GroupMyBalance: React.FC = ({ {0 < youOwe.length ? (
- {t('actors.you')} {t('ui.expense.you.owe')} + {t('ui.expense.statements.you_owe')} {youOwe.map(([currency, amount], index, arr) => (
@@ -93,8 +94,13 @@ const GroupMyBalance: React.FC = ({ {Object.entries(balances).map(([currency, amount]) => (
{0 < amount - ? `${friend?.name} ${t('ui.expense.user.owe')} ${t('actors.you_dativus').toLowerCase()}` - : `${t('actors.you')} ${t('ui.expense.you.owe')} ${friend?.name}`}{' '} + ? t('ui.expense.statements.friend_owes_you', { + friend: friend?.name ?? '', + youDative, + }) + : t('ui.expense.statements.you_owe_friend', { + friend: friend?.name ?? '', + })}{' '} {toUIString(amount)} {currency}
))} @@ -102,10 +108,11 @@ const GroupMyBalance: React.FC = ({ ); })} - {2 < Object.keys(friendBalances).length ? ( + {Object.keys(friendBalances).length > 2 ? (
- +{Object.keys(friendBalances).length - 2}{' '} - {Object.keys(friendBalances).length === 3 ? t('ui.balance') : t('ui.balances')}... + {t('ui.remaining_balances', { + count: Object.keys(friendBalances).length - 2, + })}
) : null}
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 44e372c0f..70ad0ea6d 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -18,6 +18,9 @@ import { useAddExpenseStore } from '~/store/addStore'; import { useAppStore } from '~/store/appStore'; import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; +import { registerICU } from '~/utils/i18n/registerICU'; + +registerICU(); const poppins = Poppins({ weight: ['200', '300', '400', '500', '600', '700'], subsets: ['latin'] }); const toastOptions = { duration: 1500 }; diff --git a/src/pages/auth/signin.tsx b/src/pages/auth/signin.tsx index 359824fdd..0ee44deec 100644 --- a/src/pages/auth/signin.tsx +++ b/src/pages/auth/signin.tsx @@ -75,7 +75,7 @@ const Home: NextPage<{ } else if ('SessionRequired' === error) { return; } else { - toast.error(t('errors.signin_error') + error); + toast.error(t('errors.signin_error', { error })); console.error('Error during sign-in:', error); } } diff --git a/src/pages/balances.tsx b/src/pages/balances.tsx index d8450a75e..f09f9444a 100644 --- a/src/pages/balances.tsx +++ b/src/pages/balances.tsx @@ -58,9 +58,7 @@ const BalancePage: NextPageWithUser = () => {
-

- {t('actors.you')} {t('ui.expense.you.owe')} -

+

{t('ui.expense.statements.you_owe')}

@@ -79,9 +77,7 @@ const BalancePage: NextPageWithUser = () => {
-

- {t('actors.you')} {t('ui.expense.you.lent')} -

+

{t('ui.expense.statements.you_lent')}

diff --git a/src/pages/balances/[friendId].tsx b/src/pages/balances/[friendId].tsx index e735226e4..368d970eb 100644 --- a/src/pages/balances/[friendId].tsx +++ b/src/pages/balances/[friendId].tsx @@ -82,7 +82,7 @@ const FriendPage: NextPageWithUser = ({ user }) => {
{0 < (youOwe?.length ?? 0) && ( <> - {t('actors.you')} {t('ui.expense.you.owe')}{' '} + {t('ui.expense.statements.you_owe')}{' '} {youOwe?.map((bal, index) => ( @@ -98,7 +98,7 @@ const FriendPage: NextPageWithUser = ({ user }) => {
{0 < (youLent?.length ?? 0) && ( <> - {t('actors.you')} {t('ui.expense.you.lent')}{' '} + {t('ui.expense.statements.you_lent')}{' '} {youLent?.map((bal, index) => ( diff --git a/src/utils/i18n/registerICU.ts b/src/utils/i18n/registerICU.ts new file mode 100644 index 000000000..67432e5b9 --- /dev/null +++ b/src/utils/i18n/registerICU.ts @@ -0,0 +1,13 @@ +import i18next from 'i18next'; +import ICU from 'i18next-icu'; + +let isRegistered = false; + +export const registerICU = (): void => { + if (isRegistered) { + return; + } + + i18next.use(new ICU()); + isRegistered = true; +}; diff --git a/src/utils/i18n/server.ts b/src/utils/i18n/server.ts index d34b7b74c..d6ebd0d66 100644 --- a/src/utils/i18n/server.ts +++ b/src/utils/i18n/server.ts @@ -1,6 +1,9 @@ import { type SSRConfig } from 'next-i18next'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import i18nConfig from '@/next-i18next.config.js'; +import { registerICU } from './registerICU'; + +registerICU(); export const customServerSideTranslations = async ( locale: string | undefined, diff --git a/src/utils/strings.ts b/src/utils/strings.ts index fb72bf468..6631b735d 100644 --- a/src/utils/strings.ts +++ b/src/utils/strings.ts @@ -60,7 +60,7 @@ export function generateSplitDescription( if (selectedParticipants.length === 1) { const beneficiary = selectedParticipants[0]; const beneficiaryName = displayName(t, beneficiary as User, currentUser.id) || 'someone'; - return `${t('ui.expense.user.paid')} ${t('ui.expense.for')} ${beneficiaryName}`; + return t('ui.expense.statements.paid_for_beneficiary', { beneficiary: beneficiaryName }); } // Case 2: Splitting with multiple people From 2da0d7e76e1642e8e0524cb89aa223d031b45be9 Mon Sep 17 00:00:00 2001 From: Marco Sinigaglia Date: Mon, 10 Nov 2025 17:10:11 +0100 Subject: [PATCH 02/16] Update pnpm-lock.yaml --- pnpm-lock.yaml | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d33219b4..ac53fcd1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,9 +84,15 @@ importers: i18next-http-backend: specifier: ^3.0.2 version: 3.0.2 + i18next-icu: + specifier: ^2.4.1 + version: 2.4.1(intl-messageformat@10.7.18) input-otp: specifier: ^1.2.3 version: 1.4.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + intl-messageformat: + specifier: ^10.7.18 + version: 10.7.18 lucide-react: specifier: ^0.544.0 version: 0.544.0(react@19.1.1) @@ -821,6 +827,21 @@ packages: '@floating-ui/utils@0.2.9': resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} + '@formatjs/ecma402-abstract@2.3.6': + resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==} + + '@formatjs/fast-memoize@2.2.7': + resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==} + + '@formatjs/icu-messageformat-parser@2.11.4': + resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==} + + '@formatjs/icu-skeleton-parser@1.8.16': + resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==} + + '@formatjs/intl-localematcher@0.6.2': + resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + '@heroicons/react@2.2.0': resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==} peerDependencies: @@ -3376,6 +3397,11 @@ packages: i18next-http-backend@3.0.2: resolution: {integrity: sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==} + i18next-icu@2.4.1: + resolution: {integrity: sha512-fh01aSjGlnsR377J7mu/CM3wcdZTpvNZejapPfHI+YnVyiWwvGFT2gZOgecm9B19ttyAJ3ijmHG6r/2jiQqXCA==} + peerDependencies: + intl-messageformat: ^10.3.3 + i18next@25.2.1: resolution: {integrity: sha512-+UoXK5wh+VlE1Zy5p6MjcvctHXAhRwQKCxiJD8noKZzIXmnAX8gdHX5fLPA3MEVxEN4vbZkQFy8N0LyD9tUqPw==} peerDependencies: @@ -3413,6 +3439,9 @@ packages: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + intl-messageformat@10.7.18: + resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -5759,6 +5788,32 @@ snapshots: '@floating-ui/utils@0.2.9': {} + '@formatjs/ecma402-abstract@2.3.6': + dependencies: + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/intl-localematcher': 0.6.2 + decimal.js: 10.5.0 + tslib: 2.8.1 + + '@formatjs/fast-memoize@2.2.7': + dependencies: + tslib: 2.8.1 + + '@formatjs/icu-messageformat-parser@2.11.4': + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + '@formatjs/icu-skeleton-parser': 1.8.16 + tslib: 2.8.1 + + '@formatjs/icu-skeleton-parser@1.8.16': + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.6.2': + dependencies: + tslib: 2.8.1 + '@heroicons/react@2.2.0(react@19.1.1)': dependencies: react: 19.1.1 @@ -8503,6 +8558,10 @@ snapshots: transitivePeerDependencies: - encoding + i18next-icu@2.4.1(intl-messageformat@10.7.18): + dependencies: + intl-messageformat: 10.7.18 + i18next@25.2.1(typescript@5.7.2): dependencies: '@babel/runtime': 7.27.6 @@ -8534,6 +8593,13 @@ snapshots: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) + intl-messageformat@10.7.18: + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/icu-messageformat-parser': 2.11.4 + tslib: 2.8.1 + is-arrayish@0.2.1: {} is-arrayish@0.3.2: From faeb5e1872867e979b7dc48fe9f92b1ec54007a5 Mon Sep 17 00:00:00 2001 From: Marco Sinigaglia Date: Mon, 18 May 2026 12:58:39 +0200 Subject: [PATCH 03/16] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e2d67e58d..992c19357 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ next-env.d.ts .DS_Store *.pem .idea +.opencode # debug npm-debug.log* From 034c22f21bd85686a6ab24aa2dbf2a9a61346d1c Mon Sep 17 00:00:00 2001 From: Marco Sinigaglia Date: Tue, 11 Aug 2026 16:18:26 +0200 Subject: [PATCH 04/16] refactor(i18n): migrate translations to ICU message format - add English and Italian ICU translation catalogs - replace fragmented translations with contextual messages - support locale variants with normalized fallback resolution - translate remaining UI text and notifications --- .opencode/package-lock.json | 115 ---- next-i18next.config.js | 31 +- pnpm-lock.yaml | 66 +++ public/locales/en/common_icu.json | 550 ++++++++++++++++++ public/locales/en/home_icu.json | 78 +++ public/locales/it/common_icu.json | 550 ++++++++++++++++++ public/locales/it/home_icu.json | 78 +++ src/components/Account/DebugInfo.tsx | 2 +- src/components/Account/SubmitFeedback.tsx | 6 +- src/components/AddExpense/AddExpensePage.tsx | 9 +- src/components/AddExpense/CurrencyPicker.tsx | 3 +- .../AddExpense/SelectUserOrGroup.tsx | 7 +- src/components/Expense/BalanceList.tsx | 58 +- src/components/Expense/DeleteExpense.tsx | 7 +- src/components/Expense/ExpenseDetails.tsx | 39 +- src/components/Expense/ExpenseList.tsx | 55 +- src/components/Expense/Receipt.tsx | 11 +- src/components/Friend/CurrencyConversion.tsx | 6 +- src/components/Friend/DeleteFriend.tsx | 10 +- src/components/Friend/Export.tsx | 4 +- src/components/NotificationModal.tsx | 20 +- src/components/group/GroupMyBalance.tsx | 67 +-- src/hooks/useTranslationWithUtils.ts | 2 +- src/pages/_app.tsx | 50 +- src/pages/account.tsx | 11 +- src/pages/activity.tsx | 51 +- src/pages/add.tsx | 6 +- src/pages/auth/signin.tsx | 2 +- src/pages/balances.tsx | 2 +- src/pages/balances/[friendId].tsx | 2 +- .../[friendId]/expenses/[expenseId].tsx | 2 +- src/pages/expenses/[expenseId].tsx | 2 +- src/pages/groups.tsx | 2 +- src/pages/groups/[groupId].tsx | 2 +- .../groups/[groupId]/expenses/[expenseId].tsx | 2 +- src/pages/home.tsx | 45 +- src/pages/import-splitwise.tsx | 10 +- src/pages/join-group.tsx | 3 - src/pages/recurring.tsx | 2 +- src/tests/resolveLocale.test.ts | 63 ++ src/utils/i18n/client.ts | 21 +- src/utils/i18n/registerICU.ts | 13 - src/utils/i18n/resolveLocale.ts | 55 ++ src/utils/i18n/server.ts | 14 +- src/utils/strings.ts | 13 +- 45 files changed, 1747 insertions(+), 400 deletions(-) delete mode 100644 .opencode/package-lock.json create mode 100644 public/locales/en/common_icu.json create mode 100644 public/locales/en/home_icu.json create mode 100644 public/locales/it/common_icu.json create mode 100644 public/locales/it/home_icu.json create mode 100644 src/tests/resolveLocale.test.ts delete mode 100644 src/utils/i18n/registerICU.ts create mode 100644 src/utils/i18n/resolveLocale.ts diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json deleted file mode 100644 index abded7b2a..000000000 --- a/.opencode/package-lock.json +++ /dev/null @@ -1,115 +0,0 @@ -{ - "name": ".opencode", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@opencode-ai/plugin": "1.4.3" - } - }, - "node_modules/@opencode-ai/plugin": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.3.tgz", - "integrity": "sha512-Ob/3tVSIeuMRJBr2O23RtrnC5djRe01Lglx+TwGEmjrH9yDBJ2tftegYLnNEjRoMuzITgq9LD8168p4pzv+U/A==", - "license": "MIT", - "dependencies": { - "@opencode-ai/sdk": "1.4.3", - "zod": "4.1.8" - }, - "peerDependencies": { - "@opentui/core": ">=0.1.97", - "@opentui/solid": ">=0.1.97" - }, - "peerDependenciesMeta": { - "@opentui/core": { - "optional": true - }, - "@opentui/solid": { - "optional": true - } - } - }, - "node_modules/@opencode-ai/sdk": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.3.tgz", - "integrity": "sha512-X0CAVbwoGAjTY2iecpWkx2B+GAa2jSaQKYpJ+xILopeF/OGKZUN15mjqci+L7cEuwLHV5wk3x2TStUOVCa5p0A==", - "license": "MIT", - "dependencies": { - "cross-spawn": "7.0.6" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/zod": { - "version": "4.1.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/next-i18next.config.js b/next-i18next.config.js index be2de7fb7..95b7d17b0 100644 --- a/next-i18next.config.js +++ b/next-i18next.config.js @@ -1,27 +1,22 @@ +// @ts-expect-error i18next-icu's CommonJS export has an invalid ambient module declaration. +import ICUModule from 'i18next-icu/cjs'; + +const ICU = ICUModule.default ?? ICUModule; + /** @type {import('next-i18next').UserConfig} */ const config = { i18n: { - defaultLocale: 'default', - locales: [ - 'default', - 'en', - 'de', - 'fr', - 'it', - 'cs', - 'nl', - 'pl', - 'pt-PT', - 'pt-BR', - 'sv', - 'es', - 'es-MX', - 'es-AR', - 'hu', - ], + defaultLocale: 'en', + locales: ['en', 'it'], localeDetection: false, }, + defaultNS: 'common_icu', + fallbackLng: 'en', localePath: './public/locales', + onPreInitI18next: (i18n) => { + i18n.use(ICU); + }, + serializeConfig: false, }; export default config; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b86928770..54d0cbe49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,9 +97,15 @@ importers: i18next-http-backend: specifier: ^3.0.6 version: 3.0.6 + i18next-icu: + specifier: ^2.4.1 + version: 2.4.4(intl-messageformat@10.7.18) input-otp: specifier: ^1.2.3 version: 1.4.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + intl-messageformat: + specifier: ^10.7.18 + version: 10.7.18 lucide-react: specifier: ^0.544.0 version: 0.544.0(react@19.2.1) @@ -664,6 +670,21 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@formatjs/ecma402-abstract@2.3.6': + resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==} + + '@formatjs/fast-memoize@2.2.7': + resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==} + + '@formatjs/icu-messageformat-parser@2.11.4': + resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==} + + '@formatjs/icu-skeleton-parser@1.8.16': + resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==} + + '@formatjs/intl-localematcher@0.6.2': + resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + '@heroicons/react@2.2.0': resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==} peerDependencies: @@ -3026,6 +3047,11 @@ packages: i18next-http-backend@3.0.6: resolution: {integrity: sha512-mBOqy8993jtqAoj6XaI1XeC/8/9v6EPS+681ziegrPvTB0DoaCY7PpTS0SpY56qLMoS4OI1TZEM2Zf59zNh05w==} + i18next-icu@2.4.4: + resolution: {integrity: sha512-yfYdGTftClNUnZAQG1cu0yHHLaMVsrqfcgQHHchGDufkunBLKHuw2wXTCDuE+8mWMMcdePl8UBR0EUUA/3+FDw==} + peerDependencies: + intl-messageformat: '>=10.3.3 <12.0.0' + i18next@25.10.10: resolution: {integrity: sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==} peerDependencies: @@ -3063,6 +3089,9 @@ packages: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + intl-messageformat@10.7.18: + resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -4779,6 +4808,32 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@formatjs/ecma402-abstract@2.3.6': + dependencies: + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/intl-localematcher': 0.6.2 + decimal.js: 10.5.0 + tslib: 2.8.1 + + '@formatjs/fast-memoize@2.2.7': + dependencies: + tslib: 2.8.1 + + '@formatjs/icu-messageformat-parser@2.11.4': + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + '@formatjs/icu-skeleton-parser': 1.8.16 + tslib: 2.8.1 + + '@formatjs/icu-skeleton-parser@1.8.16': + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.6.2': + dependencies: + tslib: 2.8.1 + '@heroicons/react@2.2.0(react@19.2.1)': dependencies: react: 19.2.1 @@ -7104,6 +7159,10 @@ snapshots: transitivePeerDependencies: - encoding + i18next-icu@2.4.4(intl-messageformat@10.7.18): + dependencies: + intl-messageformat: 10.7.18 + i18next@25.10.10(typescript@5.7.3): dependencies: '@babel/runtime': 7.29.2 @@ -7135,6 +7194,13 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) + intl-messageformat@10.7.18: + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/icu-messageformat-parser': 2.11.4 + tslib: 2.8.1 + is-arrayish@0.2.1: {} is-fullwidth-code-point@3.0.0: {} diff --git a/public/locales/en/common_icu.json b/public/locales/en/common_icu.json new file mode 100644 index 000000000..3603e996b --- /dev/null +++ b/public/locales/en/common_icu.json @@ -0,0 +1,550 @@ +{ + "account": { + "change_language": "Change language", + "change_language_details": { + "messages": { + "language_changed": "Language changed successfully" + }, + "no_language_found": "No language found.", + "placeholder": "Select a language", + "title": "Select a language" + }, + "download_app": "Download app", + "download_app_details": { + "download_as_pwa": "You can download SplitPro as a PWA to your home screen.", + "title": "Download app", + "using_android": "If you are using Android, checkout this", + "using_ios": "If you are using iOS, checkout this", + "video": "video" + }, + "download_splitpro_data": "Download SplitPro data", + "default_balance_currency": "Default balance currency", + "edit_name": { + "apply_avatar": "Apply avatar", + "avatar_label": "Profile picture", + "avatar_preview": "Avatar preview", + "placeholder": "Enter name", + "remove_avatar": "Remove avatar", + "select_avatar": "Select picture", + "zoom": "Zoom", + "title": "Edit details" + }, + "follow_on_x": "Follow us on X", + "import_from_splitwise": "Import from Splitwise", + "import_from_splitwise_details": { + "choose_file": "Choose file", + "export_splitwise_data_button": "Export splitwise data", + "follow_to_export_splitwise_data": "Follow this link to export splitwise data", + "messages": { + "import_success": "Import successful" + }, + "no_file_chosen": "No file chosen", + "note": "Note: It currently only supports importing friends and groups. It will not import transactions. We are working on it." + }, + "logout": "Logout", + "messages": { + "update_error": "Failed to update details", + "update_success": "Details updated" + }, + "notifications": { + "disable_notification": "Disable notification", + "enable_notification": "Enable notification", + "messages": { + "notification_granted": "You will receive notifications now" + } + }, + "star_on_github": "Star us on GitHub", + "submit_feedback": "Submit feedback", + "submit_feedback_details": { + "messages": { + "submit_error": "Failed to send feedback", + "submit_success": "Feedback sent" + }, + "placeholder": "Enter your feedback", + "title": "Submit feedback" + }, + "support_us": "Sponsor us", + "title": "Account", + "write_review": "Write a review", + "debug_info": "Debug info", + "debug_info_details": { + "title": "Debug Information", + "description": "Paste the information below when creating a support request:", + "user_agent": "UserAgent", + "git": "Git", + "version": "Version", + "new_version_available": "A new version is available:", + "copy_failed": "Failed to copy debug info to clipboard", + "send_test_notification": "Send test notification", + "test_notification_sent": "Test notification sent", + "test_notification_failed": "Could not send test notification" + } + }, + "actions": { + "add_expense": "Add expense", + "back": "Back", + "cancel": "Cancel", + "close": "Close", + "copy": "Copy", + "confirm": "Confirm", + "create": "Create", + "edit_expense": "Edit expense", + "export": "Export", + "fetch": "Fetch", + "import": "Import", + "invite": "Invite", + "leave": "Leave", + "next": "Next", + "save": "Save", + "settle_up": "Settle up", + "submit": "Submit", + "reconnect": "Reconnect", + "connect": "Connect", + "remind_later": "Remind me later" + }, + "actors": { + "all": "All", + "friends": "Friends", + "groups": "Groups", + "members": "Members", + "owner": "Owner", + "you": "You" + }, + "auth": { + "continue_with": "Continue with {provider}", + "email_placeholder": "Enter your email", + "no_providers_configured": "No authentication providers configured", + "no_providers_instructions": "Please configure at least one authentication provider to sign in. See the", + "otp_sent": "We have sent an email with the OTP. Please check your inbox and click the link in the email to sign in.", + "send_magic_link": "Send magic link", + "sending": "Sending...", + "setup_instructions": "Docker setup instructions", + "trouble_logging_in": "Trouble logging in? Contact" + }, + "currency_conversion": { + "amount_aria_label": "Amount", + "converted_amount_aria_label": "Converted amount", + "amount_to_receive": "Amount to receive", + "description": "Convert the amount to a different currency.", + "exchange_rate": "Exchange rate", + "fetching_rate": "Fetching rate...", + "rate": "Rate", + "success_toast": "Currency conversion set successfully", + "title": "Currency conversion" + }, + "errors": { + "currency_conversion_error": "Error while setting currency conversion", + "email_invalid": "Email is invalid, please try the signin flow again.", + "no_providers": "Error when fetching authentication providers. Please check backend logs.", + "email_required": "Email is required", + "feedback_min_length": "Feedback should be at least 10 characters", + "feedback_required": "Feedback is required", + "group_name_update_failed": "Error in updating group name", + "import_failed": "Error importing file", + "invalid_currency_code": "Invalid currency code {code}", + "language_change_failed": "Error changing language", + "less_than": "File should be less than {size} MB", + "image_compression_failed": "Image compression failed, trying to upload original file", + "name_required": "Name is required", + "notification_not_supported": "Notification is not supported", + "otp_invalid": "Invalid OTP", + "otp_required": "OTP is required", + "request_error": "Error requesting notification", + "notification_request_failed": "Error requesting notification permission", + "notification_subscribe_failed": "Cannot subscribe to notifications", + "expense_submission_failed": "An unexpected error occurred while submitting the expense.", + "expense_deletion_failed": "An unexpected error occurred while deleting the expense.", + "friend_deletion_failed": "Failed to delete friend", + "saving_expense": "Error while saving expense", + "setting_update_failed": "Failed to update setting", + "signin_error": "An error occurred while signing in: {error}", + "signup_disabled": "Signup of new accounts is disabled on this instance", + "something_went_wrong": "Something went wrong", + "subscribe_error": "Cannot subscribe to notification", + "unsubscribe_error": "Error unsubscribing notification", + "upload_failed": "Failed to upload file", + "uploading_error": "Error uploading file", + "valid_email": "Enter valid email", + "group_balances_malformed": "Group balances data is malformed, please report an issue or try recalculating.", + "currency_conversion_malformed": "Currency conversion data is malformed", + "invalid_cron_expression": "Invalid cron expression", + "currency_conversion_failed": "Failed to convert currencies", + "recurrence_delete_failed": "Failed to delete recurrence", + "recurrence_update_failed": "Failed to update recurrence" + }, + "bank_transactions": { + "choose_bank_provider": "Choose bank provider", + "select_bank_provider": "Select bank provider", + "search_bank": "Search bank", + "no_bank_providers_found": "No bank providers found", + "to_bank": "to bank", + "plaid": { + "bank_connected_successfully": "Bank connected successfully", + "bank_connection_failed": "Failed to connect to bank", + "bank_connection_cancelled": "Bank connection cancelled" + } + }, + "expense_details": { + "receipt": { + "title": "Expense receipt", + "thumbnail_alt": "Expense receipt thumbnail", + "image_alt": "Expense receipt" + }, + "empty_state_image_alt": "No expenses", + "add_expense": "Add expense", + "add_expense_details": { + "add_new_expense": "Add new expense", + "amount_placeholder": "Enter amount", + "description_placeholder": "Enter description", + "pick_a_date": "Pick a date", + "select_user_or_group": { + "empty_image_alt": "No users found", + "add_to_split_pro": "Add to Split Pro", + "note": "Note: sending invite is disabled for now because of spam", + "only_one_group_time": "You can have only one group at a time", + "send_invite": "Send invite to user", + "warning": "Warning: Don't use send invite if it's invalid email. use add to Split Pro instead. Your account will be blocked if this feature is misused" + }, + "split_type_section": { + "split_equally": "split equally", + "split_equally_with_count": "split equally among {count, plural, one {# person} other {# people}}", + "split_unequally": "split unequally", + "types": { + "adjustment": { + "remaining_to_split_equally": "Remaining to split equally", + "title": "Adjustment" + }, + "equal": { + "per_person": "per person", + "title": "Equal" + }, + "exact": { + "remaining": "Remaining", + "title": "Exact" + }, + "percentage": { + "remaining": "Remaining", + "title": "Percentage" + }, + "share": { + "shares": "Share(s)", + "title": "Share", + "total_shares": "Total shares" + } + } + }, + "sponsor_us": "Sponsor us", + "upload_file": { + "messages": { + "upload_success": "File uploaded successfully" + } + }, + "user_input": { + "add_more_friends": "Search friends, groups or add email", + "cannot_change_group": "Cannot change group while editing", + "remove_group": "Press delete to remove group", + "search_friends": "Search friends, groups or add email" + } + }, + "balance_list": { + "settled": "{actor, select, you {You are settled up} other {{person} is settled up}}", + "lent": "{actor, select, you {You lent} other {{person} lent}}", + "owes": "{actor, select, you {You owe} other {{person} owes}}", + "press_balance_info": "Press on individual balance to initiate settlement" + }, + "delete_expense_details": { + "text": "This action cannot be undone. This will permanently delete your expense.", + "title": "Are you absolutely sure?" + }, + "multiple_transactions": { + "title": "Add multiple transactions", + "description": "You have selected multiple transactions. Pick whether you want them added all together with the current split settings or adjust the settings one by one.", + "add_all": "Add all", + "add_one_by_one": "Add one by one" + }, + "title": "Add expense", + "title_mobile": "Add", + "clear": "Clear", + "bank_transactions": "Bank transactions", + "submit_all": "Submit all", + "pending": "Pending", + "no_transactions_yet": "No transactions yet", + "already_added": "Already added" + }, + "friend": { + "delete_confirmation": { + "title": "Are you absolutely sure?", + "description": "Do you really want to continue?", + "outstanding_balance": "You cannot remove a friend with outstanding balances. Settle up first." + } + }, + "group_details": { + "add_members": "Add members", + "copied": "Copied", + "create_group": { + "group_name_placeholder": "Group name", + "title": "Create a group" + }, + "group_info": { + "actions": "Actions", + "archive_group": "Archive group", + "configure_default_split": "Configure default split", + "default_split": "Default split", + "default_balance_currency": "Default balance currency", + "edit_group": "Edit group", + "archive_group_details": { + "can_archive": "This group will be archived and hidden from your main groups list. You can still access it later if needed.", + "cant_archive": "Cannot archive group with outstanding balances. All balances must be settled first.", + "title": "Are you sure you want to archive this group?" + }, + "archived": "Archived", + "delete_group": "Delete group", + "delete_group_details": { + "can_delete": "This action cannot be reversed", + "cant_delete": "Can't delete the group until everyone settles up the balance", + "title": "Are you absolutely sure?" + }, + "group_created": "Group created", + "leave_group": "Leave group", + "leave_group_details": { + "can_leave": "This action cannot be reversed", + "cant_leave": "Can't leave the group until your outstanding balance is settled", + "title": "Are you absolutely sure?" + }, + "members": "Members", + "recalculate_balances": "Recalculate balances", + "recalculate_balances_details": { + "description": "If balances do not match expenses, you can recalculate them. Note that it may take some time if the expense count is large. Balances outside the group will not be affected.", + "title": "Are you sure?" + }, + "remove_member_details": { + "can_remove": "You are about to remove this member from the group", + "cant_remove": "Can't remove member until their outstanding balance is settled", + "title": "Are you absolutely sure?" + }, + "simplify_debts": "Simplify debts", + "title": "Group info" + }, + "group_statistics": { + "first_expense": "First expense", + "title": "Group statistics", + "total_expenses": "Total expenses" + }, + "messages": { + "default_split_cleared": "Default split cleared", + "default_split_updated": "Default split updated", + "default_balance_currency_cleared": "Default balance currency cleared", + "default_balance_currency_updated": "Default balance currency updated", + "balances_recalculated": "Balances recalculated successfully", + "group_archived": "Group archived successfully", + "group_name_updated": "Updated group details" + }, + "no_members": { + "add_members": "Add members", + "add_members_details": { + "add_to_split_pro": "Add to Split Pro", + "note": "Note: sending email invites is disabled", + "placeholder": "Enter name or email", + "send_invite": "Send invite to user", + "title": "Add members", + "warning": "Warning: Don't use send invite if it's invalid email. use add to Split Pro instead. Your account will be blocked if this feature is misused" + }, + "invite_link": "Invite link", + "no_members": "No members in the group yet." + }, + "tabs": { + "balances": "Balances", + "expenses": "Expenses" + }, + "title": "Group outstanding balances" + }, + "invite_message": { + "in_splitpro": "in Splitpro", + "join_to": "Join to", + "text": "Join to the group and you can add, manage expenses, and track your balances" + }, + "meta": { + "application_name": "SplitPro", + "description": "Split Expenses with your friends for free", + "title": "SplitPro: Split Expenses with your friends for free" + }, + "activity": { + "you_paid_for_expense": "You paid {amount} for {expense}", + "you_received_for_expense": "You received {amount} for {expense}", + "user_paid_for_expense": "{user} paid {amount} for {expense}", + "user_received_for_expense": "{user} received {amount} for {expense}", + "user_deleted_expense": "{actor, select, you {You deleted {expense}} other {{user} deleted {expense}}}", + "you_paid_amount": "You paid {amount}", + "you_received_amount": "You received {amount}" + }, + "balance_summary": { + "friend_owes_you": "{friend} owes you {amount}", + "you_owe_friend": "You owe {friend} {amount}", + "more": "{count, plural, one {# more balance} other {# more balances}}" + }, + "balance_list": { + "pays": "{payerRole, select, you {You pay {amount} to {receiver}} other {{payer} pays {amount} to {receiverRole, select, you {you} other {{receiver}}}}}", + "gets": "{receiverRole, select, you {You get {amount} from {payer}} other {{receiver} gets {amount} from {payerRole, select, you {you} other {{payer}}}}}" + }, + "expense_participant": { + "gets": "{actor, select, you {You get {amount}} other {{person} gets {amount}}}", + "owes": "{actor, select, you {You owe {amount}} other {{person} owes {amount}}}" + }, + "group": { + "invite_title": "Join {group} in SplitPro" + }, + "bank_connection": { + "connect": "Connect to your bank", + "reconnect": "Reconnect to your bank" + }, + "expense_metadata": { + "edited_by": "{actor, select, you {Edited by you on {date}} other {Edited by {user} on {date}}}", + "deleted_by": "{actor, select, you {Deleted by you on {date}} other {Deleted by {user} on {date}}}", + "added_by": "{actor, select, you {Added by you on {date}} other {Added by {user} on {date}}}" + }, + "expense_list": { + "user_paid": "{actor, select, you {You paid {amount}} other {{user} paid {amount}}}", + "user_received": "{actor, select, you {You received {amount}} other {{user} received {amount}}}", + "you_lent": "You lent {amount}", + "you_owe": "You owe {amount}", + "settlement": "{payerRole, select, you {You paid {amount} to {receiver}} other {{receiverRole, select, you {{payer} paid you {amount}} other {{payer} paid {amount} to {receiver}}}}}", + "conversion_for_users": "Currency conversion for {payerRole, select, you {you} other {{payer}}} and {receiverRole, select, you {you} other {{receiver}}}" + }, + "balances": { + "user_preferences": { + "title": "Preferences" + }, + "default_split": { + "cleared": "Default split cleared", + "configure": "Default split", + "updated": "Default split updated" + } + }, + "navigation": { + "account": "Account", + "activity": "Activity", + "add": "Add", + "add_expense": "Add Expense", + "recurring": "Recurring", + "balances": "Balances", + "groups": "Groups" + }, + "notifications": { + "enable_title": "Enable notifications", + "enable_description": "Don't miss important events. Subscribe to receive notifications when expenses are added or updated.", + "permission_granted": "You will receive notifications now", + "subscribe": "Subscribe" + }, + "recurrence": { + "toggle_options": "Toggle recurring expense options", + "title": "Recurrence", + "description": "Set up automatic recurrence for this expense.", + "cron_expression": "Cron Expression", + "time_of_day": "Time of Day", + "days_of_week": "Days of Week", + "days_of_month": "Days of Month", + "months": "Months", + "last_day": "Last day", + "never": "No schedule configured", + "empty": "No recurring expenses yet", + "recurring": "Recurring", + "expense_for_the_amount_of": "{name} is scheduled for {amount} {currency}", + "delete_title": "Delete Recurrence", + "delete_description": "This will stop future expense creation. Existing expenses created by this recurrence will not be deleted.", + "edit_title": "Edit Recurrence", + "edit_description": "This will change the schedule for future expenses. Existing expenses will not be modified.", + "schedule_type": { + "never": "Never", + "custom": "Custom", + "day": "Daily", + "month": "Monthly", + "week": "Weekly", + "year": "Yearly" + }, + "edit_confirmation_title": "Edit Recurring Expense", + "template_edit_warning": "This is a recurring expense template. Changes will apply to all future recurring expenses.", + "derived_edit_warning": "This expense is part of a recurrence. Changes will only affect this expense.", + "template_delete_warning": "This is a recurring expense template. Deleting it will stop all future recurring expenses.", + "derived_delete_warning": "This expense is part of a recurrence. Only this expense will be deleted. Future recurring expenses will continue.", + "view_recurring_page": "View Recurring page" + }, + "ui": { + "added_by": "Added by", + "and": "and", + "balance": "balance", + "balances": "balances", + "delete": "Delete", + "deleted_by": "Deleted by", + "edited_by": "Edited by", + "expense": { + "for": "for", + "from": "from", + "paid_by": "Paid by", + "payer": { + "current_user": "You" + }, + "recipient": { + "current_user": "You" + }, + "received_by": "Received by", + "to": "to", + "user": { + "deleted": "deleted", + "get": "gets", + "lent": "lent", + "owe": "owes", + "paid": "paid", + "received": "received" + }, + "you": { + "deleted": "deleted", + "get": "get", + "lent": "lent", + "owe": "owe", + "paid": "paid", + "pay": "pay", + "received": "received" + }, + "statements": { + "you_lent": "You lent", + "you_owe": "You owe", + "you_lent_amount": "You lent {amount}", + "you_owe_amount": "You owe {amount}", + "you_paid_amount": "You paid {amount}", + "you_received_amount": "You received {amount}", + "you_pay_user": "You pay {user}", + "user_pays_you": "{user} pays you", + "friend_owes_you": "{friend} owes you", + "you_owe_friend": "You owe {friend}", + "paid_for_beneficiary": "{beneficiaryRole, select, you {This expense is only for you} other {You are splitting this expense with {beneficiary}}}", + "user_paid_amount": "{user} paid {amount}", + "user_received_amount": "{user} received {amount}", + "user_paid_amount_to_user": "{payer} paid {amount} to {receiver}", + "user_received_amount_from_user": "{payer} received {amount} from {receiver}", + "you_paid_amount_to_user": "You paid {amount} to {receiver}", + "you_received_amount_from_user": "You received {amount} from {payer}", + "for_users": "for {payer} and {receiver}", + "user_pays_user": "{payer} pays {receiver}" + } + }, + "expense_details": "Expense details", + "transfer": "{senderRole, select, you {You pay {receiver}} other {{receiverRole, select, you {{sender} pays you} other {{sender} pays {receiver}}}}}", + "no_activity": "No activities yet", + "not_involved": "Not involved", + "not_set": "Not set", + "on": "on", + "or": "or", + "outstanding_balances": "Outstanding balances", + "total_balance": "Total balance", + "select_currency": "Select currency", + "select_balance": "Select balance", + "settle_up_name": "Settle up", + "settled_up": "Settled up", + "settlement": "Settlement", + "share_text": "Check out SplitPro. It's an open source free alternative for Splitwise", + "show_all_currencies": "Show all currencies", + "today": "Today", + "in_group": "In group", + "remaining_balances": "{count, plural, =0 {No outstanding balances} one {# outstanding balance} other {# outstanding balances}}" + } +} diff --git a/public/locales/en/home_icu.json b/public/locales/en/home_icu.json new file mode 100644 index 000000000..39b8bfa02 --- /dev/null +++ b/public/locales/en/home_icu.json @@ -0,0 +1,78 @@ +{ + "features": { + "currency_conversion": { + "description": "Quickly convert between different currencies using up-to-date or historical exchange rates", + "title": "Currency Conversion" + }, + "recurring_transactions": { + "description": "Set up automatic recurring expenses for rent, subscriptions, or any regular shared costs", + "title": "Recurring Transactions" + }, + "debt_simplification": { + "description": "Automatically simplify group debts to reduce the number of transactions needed between friends", + "title": "Debt Simplification" + }, + "groups_and_friends": { + "description": "Can create multiple groups or add balance directly. Everything will be consolidated", + "title": "Groups and Friends" + }, + "i18": { + "description": "Available in multiple languages to make expense splitting accessible worldwide", + "title": "Internationalization" + }, + "bank_connection": { + "title": "Bank connection", + "description": "Add expenses faster with a integration to GoCardless or Plaid bank account transactions api" + }, + "import_splitwise": { + "description": "Don't have to manually migrate balances. You can import users and groups from splitwise", + "title": "Import from splitwise" + }, + "multiple_currencies": { + "description": "Need to add expense with different currency for same user? No problem!", + "title": "Multiple currencies" + }, + "open_source": { + "description": "Which makes it hard to become evil. Easy to self host", + "title": "Open source" + }, + "push_notifications": { + "description": "Never miss important notifications. Get notified when someone adds an expense or settles up", + "title": "Push notification" + }, + "pwa_support": { + "description": "Love mobile apps? We got you covered. Install it as a PWA and you won't even notice!", + "title": "PWA support" + }, + "title": "Features", + "unequal_split": { + "description": "Advanced split options. By shares, percentage or exact amounts.", + "title": "Unequal Split" + }, + "upload_receipts": { + "description": "Upload receipts along with the expense", + "title": "Upload Receipts" + } + }, + "footer": { + "built_by": "Built by" + }, + "hero": { + "image_alt": "SplitPro app preview", + "add_expense_button": "Add Expense", + "star_github_button": "Star us on github", + "title_before_highlight": "Split expenses with your friends for", + "title_highlight": "free", + "title_after_highlight": ".", + "subtitle": "An open source alternative to SplitWise." + }, + "meta": { + "description": "SplitPro: Split Expenses with your friends for free", + "title": "SplitPro: Split Expenses with your friends for free" + }, + "nav": { + "app_name": "SplitPro", + "privacy": "Privacy", + "terms": "Terms" + } +} diff --git a/public/locales/it/common_icu.json b/public/locales/it/common_icu.json new file mode 100644 index 000000000..58a68166c --- /dev/null +++ b/public/locales/it/common_icu.json @@ -0,0 +1,550 @@ +{ + "account": { + "change_language": "Cambia lingua", + "change_language_details": { + "messages": { + "language_changed": "Lingua cambiata con successo" + }, + "no_language_found": "Nessuna lingua trovata.", + "placeholder": "Seleziona una lingua", + "title": "Seleziona una lingua" + }, + "download_app": "Scarica l'app", + "download_app_details": { + "download_as_pwa": "Puoi scaricare SplitPro come PWA sulla tua home screen.", + "title": "Scarica l'app", + "using_android": "Se utilizzi Android, guarda questo", + "using_ios": "Se utilizzi iOS, guarda questo", + "video": "video" + }, + "download_splitpro_data": "Scarica i dati di SplitPro", + "default_balance_currency": "Valuta predefinita dei saldi", + "edit_name": { + "apply_avatar": "Applica avatar", + "avatar_label": "Immagine del profilo", + "avatar_preview": "Anteprima dell'avatar", + "placeholder": "Inserisci il nome", + "remove_avatar": "Rimuovi avatar", + "select_avatar": "Seleziona immagine", + "zoom": "Zoom", + "title": "Modifica nome" + }, + "follow_on_x": "Seguici su X", + "import_from_splitwise": "Importa da Splitwise", + "import_from_splitwise_details": { + "choose_file": "Scegli file", + "export_splitwise_data_button": "Esporta dati Splitwise", + "follow_to_export_splitwise_data": "Segui questo link per esportare i dati splitwise", + "messages": { + "import_success": "Importazione riuscita" + }, + "no_file_chosen": "Nessun file scelto", + "note": "Nota: al momento supporta solo l'importazione di amici e gruppi. Non importerà transazioni. Ci stiamo lavorando." + }, + "logout": "Esci", + "messages": { + "update_error": "Impossibile aggiornare i dettagli", + "update_success": "Dettagli aggiornati" + }, + "notifications": { + "disable_notification": "Disabilita notifiche", + "enable_notification": "Abilita notifiche", + "messages": { + "notification_granted": "Riceverai ora le notifiche" + } + }, + "star_on_github": "Dacci una stella su GitHub", + "submit_feedback": "Invia feedback", + "submit_feedback_details": { + "messages": { + "submit_error": "Impossibile inviare il feedback", + "submit_success": "Feedback inviato" + }, + "placeholder": "Inserisci il tuo feedback", + "title": "Invia feedback" + }, + "support_us": "Supportaci", + "title": "Account", + "write_review": "Scrivi una recensione", + "debug_info": "Informazioni di debug", + "debug_info_details": { + "title": "Informazioni di Debug", + "description": "Incolla le informazioni qui sotto quando crei una richiesta di assistenza:", + "user_agent": "UserAgent", + "git": "Git", + "version": "Versione", + "new_version_available": "È disponibile una nuova versione:", + "copy_failed": "Errore nel copiare le informazioni di debug", + "send_test_notification": "Invia notifica di test", + "test_notification_sent": "Notifica di test inviata", + "test_notification_failed": "Impossibile inviare la notifica di test" + } + }, + "actions": { + "add_expense": "Aggiungi spesa", + "back": "Indietro", + "cancel": "Annulla", + "close": "Chiudi", + "copy": "Copia", + "confirm": "Conferma", + "create": "Crea", + "edit_expense": "Modifica spesa", + "export": "Esporta", + "fetch": "Recupera", + "import": "Importa", + "invite": "Invita", + "leave": "Abbandona", + "next": "Avanti", + "save": "Salva", + "settle_up": "Regola", + "submit": "Invia", + "reconnect": "Riconnettiti", + "connect": "Connettiti", + "remind_later": "Ricordamelo più tardi" + }, + "actors": { + "all": "Tutti", + "friends": "Amici", + "groups": "Gruppi", + "members": "Membri", + "owner": "Proprietario", + "you": "Tu" + }, + "auth": { + "continue_with": "Continua con {provider}", + "email_placeholder": "Inserisci la tua email", + "no_providers_configured": "Nessun provider di autenticazione configurato", + "no_providers_instructions": "Si prega di configurare almeno un provider di autenticazione per accedere. Consulta le", + "otp_sent": "Abbiamo inviato un'e-mail con l'OTP. Controllare la posta in arrivo e fare clic sul link contenuto nell'e-mail per accedere.", + "send_magic_link": "Invia link magico", + "sending": "Invio in corso...", + "setup_instructions": "istruzioni di configurazione Docker", + "trouble_logging_in": "Problemi ad accedere? Contatta" + }, + "currency_conversion": { + "amount_aria_label": "Importo", + "converted_amount_aria_label": "Importo convertito", + "amount_to_receive": "Importo da ricevere", + "description": "Converti l'importo in una valuta diversa.", + "exchange_rate": "Tasso di cambio", + "fetching_rate": "Recupero tasso...", + "rate": "Tasso", + "success_toast": "Conversione valuta impostata con successo", + "title": "Conversione valuta" + }, + "errors": { + "currency_conversion_error": "Errore durante l'impostazione della conversione valuta", + "email_invalid": "L'e-mail non è valida, si prega di riprovare l'accesso.", + "no_providers": "Errore durante lo scaricamento dei provider di autenticazione. Per favore controllare i log backend.", + "email_required": "Email richiesta", + "feedback_min_length": "Il feedback deve contenere almeno 10 caratteri", + "feedback_required": "Il feedback è obbligatorio", + "group_name_update_failed": "Errore nell'aggiornamento del nome del gruppo", + "import_failed": "Errore durante l'importazione del file", + "invalid_currency_code": "Codice valuta {code} non valido", + "language_change_failed": "Errore nel cambiare lingua", + "less_than": "Il file deve essere più piccolo di {size} MB", + "image_compression_failed": "Compressione dell'immagine fallita, provo a caricare il file originale", + "name_required": "Il nome è obbligatorio", + "notification_not_supported": "Le notifiche non sono supportate", + "otp_invalid": "OTP non valido", + "otp_required": "OTP richiesto", + "request_error": "Errore durante la richiesta delle notifiche", + "notification_request_failed": "Errore nella richiesta del permesso per le notifiche", + "notification_subscribe_failed": "Impossibile iscriversi alle notifiche", + "expense_submission_failed": "Si è verificato un errore imprevisto durante l'invio della spesa.", + "expense_deletion_failed": "Si è verificato un errore imprevisto durante l'eliminazione della spesa.", + "friend_deletion_failed": "Impossibile eliminare l'amico", + "saving_expense": "Errore durante il salvataggio della spesa", + "setting_update_failed": "Errore nell'aggiornamento dell'impostazione", + "signin_error": "Si è verificato un errore durante l’accesso: {error}", + "signup_disabled": "La registrazione di nuovi account è disabilitata in questa istanza", + "something_went_wrong": "Qualcosa è andato storto", + "subscribe_error": "Impossibile attivare le notifiche", + "unsubscribe_error": "Errore durante la disattivazione delle notifiche", + "upload_failed": "Caricamento file fallito", + "uploading_error": "Errore durante il caricamento del file", + "valid_email": "Inserisci un'email valida", + "group_balances_malformed": "I dati relativi ai saldi di gruppo sono errati, segnalare il problema o provare a ricalcolarli.", + "currency_conversion_malformed": "I dati relativi alla conversione valutaria sono errati", + "invalid_cron_expression": "Espressione cron non valida", + "currency_conversion_failed": "Impossibile convertire le valute", + "recurrence_delete_failed": "Impossibile eliminare la ricorrenza", + "recurrence_update_failed": "Impossibile aggiornare la ricorrenza" + }, + "bank_transactions": { + "choose_bank_provider": "Scegli il fornitore bancario", + "select_bank_provider": "Seleziona il fornitore bancario", + "search_bank": "Ricerca banca", + "no_bank_providers_found": "Nessun fornitore bancario trovato", + "to_bank": "alla banca", + "plaid": { + "bank_connected_successfully": "Connessione alla banca riuscita", + "bank_connection_failed": "Impossibile connettersi alla banca", + "bank_connection_cancelled": "Connessione alla banca annullata" + } + }, + "expense_details": { + "receipt": { + "title": "Ricevuta della spesa", + "thumbnail_alt": "Miniatura della ricevuta della spesa", + "image_alt": "Ricevuta della spesa" + }, + "empty_state_image_alt": "Nessuna spesa", + "add_expense": "Aggiungi spesa", + "add_expense_details": { + "add_new_expense": "Aggiungi nuova spesa", + "amount_placeholder": "Inserisci importo", + "description_placeholder": "Inserisci descrizione", + "pick_a_date": "Scegli una data", + "select_user_or_group": { + "empty_image_alt": "Nessun utente trovato", + "add_to_split_pro": "Aggiungi a Split Pro", + "note": "Nota: l'invio di inviti è disabilitato per ora a causa dello spam", + "only_one_group_time": "Puoi operare su un solo gruppo alla volta", + "send_invite": "Invia invito all'utente", + "warning": "Attenzione: non usare \"Invia invito\" se l'email non è valida. Usa invece \"Aggiungi a Split Pro\". Il tuo account verrà bloccato se questa funzione viene utilizzata in modo improprio" + }, + "split_type_section": { + "split_equally": "diviso equamente", + "split_equally_with_count": "dividi equamente tra {count, plural, one {# persona} other {# persone}}", + "split_unequally": "diviso non equamente", + "types": { + "adjustment": { + "remaining_to_split_equally": "Rimanente da dividere equamente", + "title": "Aggiustamento" + }, + "equal": { + "per_person": "a persona", + "title": "Uguale" + }, + "exact": { + "remaining": "Rimanenti", + "title": "Esatto" + }, + "percentage": { + "remaining": "Rimanenti", + "title": "Percentuale" + }, + "share": { + "shares": "Condivisione/i", + "title": "Condivisioni", + "total_shares": "Condivisioni totali" + } + } + }, + "sponsor_us": "Supportaci", + "upload_file": { + "messages": { + "upload_success": "File caricato con successo" + } + }, + "user_input": { + "add_more_friends": "Cerca amici, gruppi o aggiungi email", + "cannot_change_group": "Impossibile cambiare gruppo durante la modifica", + "remove_group": "Premi elimina per rimuovere il gruppo", + "search_friends": "Cerca amici, gruppi o aggiungi email" + } + }, + "balance_list": { + "settled": "{actor, select, you {Sei in pari} other {{person} è in pari}}", + "lent": "{actor, select, you {Hai prestato} other {{person} ha prestato}}", + "owes": "{actor, select, you {Devi} other {{person} deve}}", + "press_balance_info": "Premi sul saldo individuale per avviare il regolamento" + }, + "delete_expense_details": { + "text": "Questa azione non può essere annullata. La spesa verrà eliminata definitivamente.", + "title": "Sei sicuro di voler eliminare questa spesa?" + }, + "multiple_transactions": { + "title": "Aggiungi più transazioni", + "description": "Hai selezionato più transazioni. Scegli se desideri aggiungerle tutte insieme con le impostazioni di suddivisione correnti o modificare le impostazioni una per una.", + "add_all": "Aggiungi tutto", + "add_one_by_one": "Aggiungi uno alla volta" + }, + "title": "Aggiungi spesa", + "title_mobile": "Aggiungi", + "clear": "Pulisci", + "bank_transactions": "Transazioni bancarie", + "submit_all": "Invia tutto", + "pending": "In sospeso", + "no_transactions_yet": "Nessuna transazione ancora", + "already_added": "Già aggiunto" + }, + "friend": { + "delete_confirmation": { + "title": "Sei assolutamente sicuro?", + "description": "Vuoi davvero continuare?", + "outstanding_balance": "Non puoi rimuovere un amico con saldi in sospeso. Regola prima i saldi." + } + }, + "group_details": { + "add_members": "Aggiungi membri", + "copied": "Copiato", + "create_group": { + "group_name_placeholder": "Nome del gruppo", + "title": "Crea un gruppo" + }, + "group_info": { + "actions": "Azioni", + "archive_group": "Archivia gruppo", + "configure_default_split": "Configura divisione predefinita", + "default_split": "Divisione predefinita", + "default_balance_currency": "Valuta predefinita dei saldi", + "edit_group": "Modifica gruppo", + "archive_group_details": { + "can_archive": "Questo gruppo verrà archiviato e nascosto dalla tua lista principale dei gruppi. Potrai comunque accedervi più tardi se necessario.", + "cant_archive": "Non è possibile archiviare un gruppo con saldi in sospeso. Tutti i saldi devono essere saldati prima.", + "title": "Sei sicuro di voler archiviare questo gruppo?" + }, + "archived": "Archiviato", + "delete_group": "Elimina gruppo", + "delete_group_details": { + "can_delete": "L'azione non sarà reversibile", + "cant_delete": "Non è possibile eliminare il gruppo finché tutti non saldano il saldo", + "title": "Sei assolutamente sicuro?" + }, + "group_created": "Creazione gruppo", + "leave_group": "Abbandona gruppo", + "leave_group_details": { + "can_leave": "L'azione non sarà reversibile", + "cant_leave": "Non puoi lasciare il gruppo finché il saldo in sospeso non sarà saldato", + "title": "Sei assolutamente sicuro?" + }, + "members": "Membri", + "recalculate_balances": "Ricalcola saldi", + "recalculate_balances_details": { + "description": "Se i saldi non corrispondono alle spese, puoi ricalcolarli. Nota che potrebbe richiedere del tempo se il numero di spese è elevato. I saldi esterni al gruppo non saranno interessati.", + "title": "Sei sicuro?" + }, + "remove_member_details": { + "can_remove": "Stai per rimuovere questo membro dal gruppo", + "cant_remove": "Non puoi rimuovere il membro finché il suo saldo non è saldato", + "title": "Sei assolutamente sicuro?" + }, + "simplify_debts": "Semplifica debiti", + "title": "Informazioni gruppo" + }, + "group_statistics": { + "first_expense": "Prima spesa", + "title": "Statistiche gruppo", + "total_expenses": "Spese totali" + }, + "messages": { + "default_split_cleared": "Divisione predefinita rimossa", + "default_split_updated": "Divisione predefinita aggiornata", + "default_balance_currency_cleared": "Valuta predefinita dei saldi rimossa", + "default_balance_currency_updated": "Valuta predefinita dei saldi aggiornata", + "balances_recalculated": "Saldi ricalcolati con successo", + "group_archived": "Gruppo archiviato con successo", + "group_name_updated": "Nome del gruppo aggiornato" + }, + "no_members": { + "add_members": "Aggiungi membri", + "add_members_details": { + "add_to_split_pro": "Aggiungi a Split Pro", + "note": "Nota: l'invio di inviti è disabilitato per ora a causa dello spam", + "placeholder": "Inserisci il nome o l'email", + "send_invite": "Invia invito all'utente", + "title": "Aggiungi membri", + "warning": "Attenzione: non usare \"Invia invito\" se l'email non è valida. Usa invece \"Aggiungi a Split Pro\". Il tuo account verrà bloccato se questa funzione viene utilizzata in modo improprio" + }, + "invite_link": "Condividi il link d'invito", + "no_members": "Nessun membro nel gruppo ancora." + }, + "tabs": { + "balances": "Saldi", + "expenses": "Spese" + }, + "title": "Saldi in sospeso del gruppo" + }, + "invite_message": { + "in_splitpro": "su SplitPro", + "join_to": "Unisciti a", + "text": "Unisciti al gruppo e potrai aggiungere, gestire spese e tracciare i tuoi saldi" + }, + "meta": { + "application_name": "SplitPro", + "description": "Dividi le spese con i tuoi amici gratuitamente", + "title": "SplitPro: Dividi le spese con i tuoi amici gratuitamente" + }, + "activity": { + "you_paid_for_expense": "Hai pagato {amount} per {expense}", + "you_received_for_expense": "Hai ricevuto {amount} per {expense}", + "user_paid_for_expense": "{user} ha pagato {amount} per {expense}", + "user_received_for_expense": "{user} ha ricevuto {amount} per {expense}", + "user_deleted_expense": "{actor, select, you {Hai eliminato {expense}} other {{user} ha eliminato {expense}}}", + "you_paid_amount": "Hai pagato {amount}", + "you_received_amount": "Hai ricevuto {amount}" + }, + "balance_summary": { + "friend_owes_you": "{friend} ti deve {amount}", + "you_owe_friend": "Devi {amount} a {friend}", + "more": "{count, plural, one {# saldo in più} other {# saldi in più}}" + }, + "balance_list": { + "pays": "{payerRole, select, you {Paghi {amount} a {receiver}} other {{payer} paga {amount} a {receiverRole, select, you {te} other {{receiver}}}}}", + "gets": "{receiverRole, select, you {Ricevi {amount} da {payer}} other {{receiver} riceve {amount} da {payerRole, select, you {te} other {{payer}}}}}" + }, + "expense_participant": { + "gets": "{actor, select, you {Ricevi {amount}} other {{person} riceve {amount}}}", + "owes": "{actor, select, you {Devi {amount}} other {{person} deve {amount}}}" + }, + "group": { + "invite_title": "Unisciti a {group} su SplitPro" + }, + "bank_connection": { + "connect": "Collegati alla tua banca", + "reconnect": "Ricollegati alla tua banca" + }, + "expense_metadata": { + "edited_by": "{actor, select, you {Modificato da te il {date}} other {Modificato da {user} il {date}}}", + "deleted_by": "{actor, select, you {Eliminato da te il {date}} other {Eliminato da {user} il {date}}}", + "added_by": "{actor, select, you {Aggiunto da te il {date}} other {Aggiunto da {user} il {date}}}" + }, + "expense_list": { + "user_paid": "{actor, select, you {Hai pagato {amount}} other {{user} ha pagato {amount}}}", + "user_received": "{actor, select, you {Hai ricevuto {amount}} other {{user} ha ricevuto {amount}}}", + "you_lent": "Hai prestato {amount}", + "you_owe": "Devi {amount}", + "settlement": "{payerRole, select, you {Hai pagato {amount} a {receiver}} other {{receiverRole, select, you {{payer} ti ha pagato {amount}} other {{payer} ha pagato {amount} a {receiver}}}}}", + "conversion_for_users": "Conversione di valuta tra {payerRole, select, you {te} other {{payer}}} e {receiverRole, select, you {te} other {{receiver}}}" + }, + "balances": { + "user_preferences": { + "title": "Preferenze" + }, + "default_split": { + "cleared": "Divisione predefinita rimossa", + "configure": "Divisione predefinita", + "updated": "Divisione predefinita aggiornata" + } + }, + "navigation": { + "account": "Account", + "activity": "Attività", + "add": "Aggiungi", + "add_expense": "Aggiungi spesa", + "recurring": "Ricorrente", + "balances": "Saldi", + "groups": "Gruppi" + }, + "notifications": { + "enable_title": "Abilita notifiche", + "enable_description": "Non perdere gli eventi importanti. Iscriviti per ricevere notifiche quando vengono aggiunte o aggiornate delle spese.", + "permission_granted": "Riceverai ora le notifiche", + "subscribe": "Iscriviti" + }, + "recurrence": { + "toggle_options": "Mostra o nascondi le opzioni della spesa ricorrente", + "title": "Ricorrenza", + "description": "Imposta una ricorrenza automatica per questa spesa.", + "cron_expression": "Espressione Cron", + "time_of_day": "Ora del giorno", + "days_of_week": "Giorni della settima", + "days_of_month": "Giorni del mese", + "months": "Mesi", + "last_day": "Ultimo giorno", + "never": "Nessuna pianificazione configurata", + "empty": "Ancora nessuna spesa ricorrente", + "recurring": "Ricorrente", + "expense_for_the_amount_of": "{name} è programmata per {amount} {currency}", + "delete_title": "Elimina ricorrenza", + "delete_description": "Questo interromperà la creazione di spese future. Le spese già create da questa ricorrenza non saranno eliminate.", + "edit_title": "Modifica ricorrenza", + "edit_description": "Questo cambierà il programma delle spese future. Le spese già create non saranno modificate.", + "schedule_type": { + "never": "Mai", + "custom": "Personalizzata", + "day": "Giornaliera", + "month": "Mensile", + "week": "Settimanale", + "year": "Annuale" + }, + "edit_confirmation_title": "Modifica spesa ricorrente", + "template_edit_warning": "Questa è una spesa ricorrente modello. Le modifiche si applicheranno a tutte le future spese ricorrenti.", + "derived_edit_warning": "Questa spesa fa parte di una ricorrenza. Le modifiche influenzeranno solo questa spesa.", + "template_delete_warning": "Questa è una spesa ricorrente modello. Eliminandola si interromperanno tutte le future spese ricorrenti.", + "derived_delete_warning": "Questa spesa fa parte di una ricorrenza. Verrà eliminata solo questa spesa. Le future spese ricorrenti continueranno.", + "view_recurring_page": "Visualizza pagina ricorrenze" + }, + "ui": { + "added_by": "Aggiunto da", + "and": "e", + "balance": "saldo", + "balances": "saldi", + "delete": "Elimina", + "deleted_by": "Eliminato da", + "edited_by": "Modificato da", + "expense": { + "for": "per", + "from": "da", + "paid_by": "Pagato da", + "payer": { + "current_user": "Te" + }, + "recipient": { + "current_user": "Te" + }, + "received_by": "Ricevuto da", + "to": "a", + "user": { + "deleted": "ha eliminato", + "get": "riceve", + "lent": "ha prestato", + "owe": "deve", + "paid": "ha pagato", + "received": "ha ricevuto" + }, + "you": { + "deleted": "hai eliminato", + "get": "ricevi", + "lent": "hai prestato", + "owe": "devi", + "paid": "hai pagato", + "pay": "paga", + "received": "hai ricevuto" + }, + "statements": { + "you_lent": "Hai prestato", + "you_owe": "Devi", + "you_lent_amount": "Hai prestato {amount}", + "you_owe_amount": "Devi {amount}", + "you_paid_amount": "Hai pagato {amount}", + "you_received_amount": "Hai ricevuto {amount}", + "you_pay_user": "Paghi {user}", + "user_pays_you": "{user} ti paga", + "friend_owes_you": "{friend} ti deve", + "you_owe_friend": "Devi {friend}", + "paid_for_beneficiary": "{beneficiaryRole, select, you {Questa spesa riguarda solo te} other {Stai dividendo questa spesa con {beneficiary}}}", + "user_paid_amount": "{user} ha pagato {amount}", + "user_received_amount": "{user} ha ricevuto {amount}", + "user_paid_amount_to_user": "{payer} ha pagato {amount} a {receiver}", + "user_received_amount_from_user": "{payer} ha ricevuto {amount} da {receiver}", + "you_paid_amount_to_user": "Hai pagato {amount} a {receiver}", + "you_received_amount_from_user": "Hai ricevuto {amount} da {payer}", + "for_users": "per {payer} e {receiver}", + "user_pays_user": "{payer} paga {receiver}" + } + }, + "expense_details": "Dettagli spesa", + "transfer": "{senderRole, select, you {Paghi {receiver}} other {{receiverRole, select, you {{sender} ti paga} other {{sender} paga {receiver}}}}}", + "no_activity": "Nessuna attività ancora", + "not_involved": "Non coinvolto", + "not_set": "Non impostato", + "on": "il", + "or": "o", + "outstanding_balances": "Saldi in sospeso", + "total_balance": "Saldo totale", + "select_currency": "Seleziona valuta", + "select_balance": "Seleziona saldo", + "settle_up_name": "Regola", + "settled_up": "Regolato", + "settlement": "Regolamento", + "share_text": "Dai un'occhiata a SplitPro. È un'alternativa gratuita e open source a Splitwise", + "show_all_currencies": "Mostra tutte le valute", + "today": "Oggi", + "in_group": "Nel gruppo", + "remaining_balances": "{count, plural, =0 {Nessun saldo aperto} one {# saldo aperto} other {# saldi aperti}}" + } +} diff --git a/public/locales/it/home_icu.json b/public/locales/it/home_icu.json new file mode 100644 index 000000000..126f59a79 --- /dev/null +++ b/public/locales/it/home_icu.json @@ -0,0 +1,78 @@ +{ + "features": { + "currency_conversion": { + "description": "Converti rapidamente tra diverse valute utilizzando tassi di cambio aggiornati o storici", + "title": "Conversione Valuta" + }, + "recurring_transactions": { + "description": "Configura spese ricorrenti automatiche per affitto, abbonamenti o altri costi condivisi ricorrenti", + "title": "Spese ricorrenti" + }, + "debt_simplification": { + "description": "Semplifica automaticamente i debiti di gruppo per ridurre il numero di transazioni necessarie tra amici", + "title": "Semplificazione Debiti" + }, + "groups_and_friends": { + "description": "Puoi creare più gruppi o aggiungere saldi direttamente. Tutto verrà consolidato", + "title": "Gruppi e Amici" + }, + "i18": { + "description": "Disponibile in più lingue per rendere la divisione delle spese accessibile in tutto il mondo", + "title": "Internazionalizzazione" + }, + "bank_connection": { + "title": "Connessione bancaria", + "description": "Aggiungi spese più velocemente con un'integrazione alle API delle transazioni bancarie di GoCardless o Plaid" + }, + "import_splitwise": { + "description": "Non devi migrare manualmente i saldi. Puoi importare utenti e gruppi da Splitwise", + "title": "Importa da Splitwise" + }, + "multiple_currencies": { + "description": "Devi aggiungere una spesa con una valuta diversa per lo stesso utente? Nessun problema!", + "title": "Valute multiple" + }, + "open_source": { + "description": "Il che rende difficile farci diventare cattivi. Facile da hostare autonomamente", + "title": "Open source" + }, + "push_notifications": { + "description": "Non perdere mai le notifiche importanti. Ricevi notifiche quando qualcuno aggiunge una spesa o un saldo", + "title": "Notifiche push" + }, + "pwa_support": { + "description": "Ami le app mobile? Abbiamo pensato anche a te. Installala come PWA e non ti accorgerai della differenza!", + "title": "Supporto PWA" + }, + "title": "Funzionalità", + "unequal_split": { + "description": "Opzioni di divisione avanzate. Per quote, percentuale o importi esatti.", + "title": "Divisione ineguale" + }, + "upload_receipts": { + "description": "Carica gli scontrini insieme alla spesa", + "title": "Carica Scontrini" + } + }, + "footer": { + "built_by": "Creato da" + }, + "hero": { + "image_alt": "Anteprima dell'app SplitPro", + "add_expense_button": "Aggiungi una spesa", + "star_github_button": "Dacci una stella su github", + "title_before_highlight": "Dividi le spese con i tuoi amici", + "title_highlight": "gratis", + "title_after_highlight": ".", + "subtitle": "Un'alternativa open source a SplitWise." + }, + "meta": { + "description": "SplitPro: Dividi le spese con i tuoi amici gratuitamente", + "title": "SplitPro: Dividi le spese con i tuoi amici gratuitamente" + }, + "nav": { + "app_name": "SplitPro", + "privacy": "Informativa sulla privacy", + "terms": "Termini" + } +} diff --git a/src/components/Account/DebugInfo.tsx b/src/components/Account/DebugInfo.tsx index 6f097d27a..83c16f7e9 100644 --- a/src/components/Account/DebugInfo.tsx +++ b/src/components/Account/DebugInfo.tsx @@ -18,7 +18,7 @@ import { cn } from '~/lib/utils'; import { Button } from '../ui/button'; export const DebugInfo: React.FC = ({ children }) => { - const { t } = useTranslation('common'); + const { t } = useTranslation('common_icu'); const [newVersion, setNewVersion] = React.useState(null); const sendTestPushNotification = api.user.sendTestPushNotification.useMutation(); diff --git a/src/components/Account/SubmitFeedback.tsx b/src/components/Account/SubmitFeedback.tsx index abfe4c848..714d40113 100644 --- a/src/components/Account/SubmitFeedback.tsx +++ b/src/components/Account/SubmitFeedback.tsx @@ -36,10 +36,12 @@ export const SubmitFeedback: React.FC = () => { try { await submitFeedbackMutation.mutateAsync({ feedback: values.feedback }); feedbackForm.reset(); - toast.success(t('ui.messages.submit_success'), { duration: 1500 }); + toast.success(t('account.submit_feedback_details.messages.submit_success'), { + duration: 1500, + }); } catch (e) { console.info(e); - toast.error(t('ui.messages.submit_error')); + toast.error(t('account.submit_feedback_details.messages.submit_error')); } setFeedbackOpen(false); }, diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index cb10c8210..8288fafb6 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -190,7 +190,7 @@ export const AddOrEditExpensePage: React.FC<{ if (error instanceof Error) { toast.error(error.message); } else { - toast.error('An unexpected error occurred while submitting the expense.'); + toast.error(t('errors.expense_submission_failed')); } } }, [ @@ -216,6 +216,7 @@ export const AddOrEditExpensePage: React.FC<{ cronExpression, multipleTransactions, setSingleTransaction, + t, update, ]); @@ -340,7 +341,9 @@ export const AddOrEditExpensePage: React.FC<{ className="text-primary h-8 max-w-full min-w-0 justify-start px-1.5 py-0 text-base sm:max-w-none" > - {displayName(paidBy, currentUser?.id, 'dativus')} + {paidBy?.id === currentUser?.id + ? t(`ui.expense.${isNegative ? 'recipient' : 'payer'}.current_user`) + : displayName(paidBy)} @@ -398,7 +401,7 @@ export const AddOrEditExpensePage: React.FC<{ 'size-6', )} /> - Toggle recurring expense options + {t('recurrence.toggle_options')} )} diff --git a/src/components/AddExpense/CurrencyPicker.tsx b/src/components/AddExpense/CurrencyPicker.tsx index 4489670b3..c3e8737f1 100644 --- a/src/components/AddExpense/CurrencyPicker.tsx +++ b/src/components/AddExpense/CurrencyPicker.tsx @@ -62,7 +62,8 @@ function CurrencyPickerInner({ { code: '__CLEAR__' as const, label: - t('ui.not_set', { ns: 'common' }) ?? t('expense_details.clear', { ns: 'common' }), + t('ui.not_set', { ns: 'common_icu' }) ?? + t('expense_details.clear', { ns: 'common_icu' }), isClear: true, }, ...items, diff --git a/src/components/AddExpense/SelectUserOrGroup.tsx b/src/components/AddExpense/SelectUserOrGroup.tsx index a2190d11b..a54d1f37a 100644 --- a/src/components/AddExpense/SelectUserOrGroup.tsx +++ b/src/components/AddExpense/SelectUserOrGroup.tsx @@ -198,7 +198,12 @@ export const SelectUserOrGroup: React.FC<{ {0 === filteredFriends?.length && 0 === filteredGroups?.length ? (
- empty user image + {t('expense_details.add_expense_details.select_user_or_group.empty_image_alt')}
) : null}
diff --git a/src/components/Expense/BalanceList.tsx b/src/components/Expense/BalanceList.tsx index 311ab2992..a229a68eb 100644 --- a/src/components/Expense/BalanceList.tsx +++ b/src/components/Expense/BalanceList.tsx @@ -47,7 +47,7 @@ export const BalanceList: React.FC<{ total: {}, }; console.error('BalanceList: userId not found in users list', balance.userId); - toast.error(t('common:errors.group_balances_malformed')); + toast.error(t('errors.group_balances_malformed')); } if (!res[balance.userId]!.balances[balance.friendId]) { res[balance.userId]!.balances[balance.friendId] = {}; @@ -80,21 +80,20 @@ export const BalanceList: React.FC<{
- {displayName(user, userQuery.data?.id)} {Object.values(total).every((amount) => 0n === amount) ? ( - {' '} - {isCurrentUser - ? t('expense_details.balance_list.are_settled_up') - : t('expense_details.balance_list.is_settled_up')} + {t('expense_details.balance_list.settled', { + actor: isCurrentUser ? 'you' : 'other', + person: displayName(user), + })} ) : ( <> - {' '} - {t( - `ui.expense.${isCurrentUser ? 'you' : 'user'}.${0 < totalAmount[1] ? 'lent' : 'owe'}`, - )}{' '} + {t(`expense_details.balance_list.${0 < totalAmount[1] ? 'lent' : 'owes'}`, { + actor: isCurrentUser ? 'you' : 'other', + person: displayName(user), + })}{' '}
- {displayName(friend, userQuery.data?.id)} - - {' '} - {t( - `ui.expense.${friend.id === userQuery.data?.id ? 'you' : 'user'}.${0 > amount ? 'get' : 'pay'}`, - )}{' '} - - - {getCurrencyHelpersCached(currency).toUIString(BigMath.abs(amount))} - - - {' '} - {t(`ui.expense.${0 < amount ? 'to' : 'from'}`, { - ns: 'common', - })}{' '} - - - {displayName(user, userQuery.data?.id, 'accusativus')} - + {t(0 < amount ? 'balance_list.pays' : 'balance_list.gets', { + payerRole: + (0 < amount ? friend : user).id === userQuery.data?.id + ? 'you' + : 'other', + receiverRole: + (0 < amount ? user : friend).id === userQuery.data?.id + ? 'you' + : 'other', + payer: displayName(0 < amount ? friend : user), + receiver: displayName(0 < amount ? user : friend), + amount: getCurrencyHelpersCached(currency).toUIString( + BigMath.abs(amount), + ), + })}
diff --git a/src/components/Expense/DeleteExpense.tsx b/src/components/Expense/DeleteExpense.tsx index ea25baabd..1831df96c 100644 --- a/src/components/Expense/DeleteExpense.tsx +++ b/src/components/Expense/DeleteExpense.tsx @@ -38,15 +38,16 @@ export const DeleteExpense: React.FC = ({ expenseId, recurre await deleteExpenseMutation.mutateAsync({ expenseId }); } catch (error) { if (error instanceof Error) { - toast.error(`Error: ${error.message}`); + console.error(error); + toast.error(t('errors.expense_deletion_failed')); } else { console.error('Unexpected error:', error); - toast.error('An unexpected error occurred while deleting the expense.'); + toast.error(t('errors.expense_deletion_failed')); } return; } router.back(); - }, [expenseId, deleteExpenseMutation, router]); + }, [expenseId, deleteExpenseMutation, router, t]); const description = useMemo(() => { if (!isPartOfRecurrence) { diff --git a/src/components/Expense/ExpenseDetails.tsx b/src/components/Expense/ExpenseDetails.tsx index 59d313b99..54acf4090 100644 --- a/src/components/Expense/ExpenseDetails.tsx +++ b/src/components/Expense/ExpenseDetails.tsx @@ -1,6 +1,7 @@ import { SplitType } from '@prisma/client'; import { isSameDay } from 'date-fns'; import { type User as NextUser } from 'next-auth'; +import { useSession } from 'next-auth/react'; import type { inferRouterOutputs } from '@trpc/server'; import { ArrowRightIcon, Landmark, Merge, PencilIcon, Users } from 'lucide-react'; @@ -77,19 +78,28 @@ const ExpenseDetails: React.FC = ({ user, expense }) => { ) : null} {expense.updatedByUser ? (

- {t('ui.edited_by')} {displayName(expense.updatedByUser, user.id, 'dativus')}{' '} - {t('ui.on')} {toUIDate(expense.updatedAt, { year: true })} + {t('expense_metadata.edited_by', { + actor: expense.updatedByUser.id === user.id ? 'you' : 'other', + user: displayName(expense.updatedByUser), + date: toUIDate(expense.updatedAt, { year: true }), + })}

) : null} {expense.deletedByUser ? (

- {t('ui.deleted_by')} {displayName(expense.deletedByUser, user.id, 'dativus')}{' '} - {t('ui.on')} {toUIDate(expense.deletedAt ?? expense.createdAt, { year: true })} + {t('expense_metadata.deleted_by', { + actor: expense.deletedByUser.id === user.id ? 'you' : 'other', + user: displayName(expense.deletedByUser), + date: toUIDate(expense.deletedAt ?? expense.createdAt, { year: true }), + })}

) : (

- {t('ui.added_by')} {displayName(expense.addedByUser, user.id, 'dativus')}{' '} - {t('ui.on')} {toUIDate(expense.createdAt, { year: true })} + {t('expense_metadata.added_by', { + actor: expense.addedByUser.id === user.id ? 'you' : 'other', + user: displayName(expense.addedByUser), + date: toUIDate(expense.createdAt, { year: true }), + })}

)} {expense.recurrence ? ( @@ -186,10 +196,13 @@ const ExpenseParticipantEntry: React.FC<{ {displayName(participant.user, userId)} - - {t(`ui.expense.${isCurrentUser ? 'you' : 'user'}.${isPositive ? 'get' : 'owe'}`)} + + {t(isPositive ? 'expense_participant.gets' : 'expense_participant.owes', { + actor: isCurrentUser ? 'you' : 'other', + person: displayName(participant.user), + amount: toUIString(participant.amount), + })} - {toUIString(participant.amount)}
); }; @@ -261,6 +274,7 @@ export const EditCurrencyConversion: React.FC<{ expense: ExpenseDetailsOutput }> export const EditSettlement: React.FC<{ expense: ExpenseDetailsOutput }> = ({ expense }) => { const { displayName, t, getCurrencyHelpersCached } = useTranslationWithUtils(); + const { data: session } = useSession(); const sender = expense.paidByUser; const receiver = expense.expenseParticipants.find((p) => p.userId !== expense.paidBy)?.user; @@ -352,7 +366,12 @@ export const EditSettlement: React.FC<{ expense: ExpenseDetailsOutput }> = ({ ex

- {displayName(sender)} {t('ui.expense.user.pay')} {displayName(receiver)} + {t('ui.transfer', { + senderRole: sender.id === session?.user.id ? 'you' : 'other', + receiverRole: receiver.id === session?.user.id ? 'you' : 'other', + sender: displayName(sender), + receiver: displayName(receiver), + })}

{expense.group ? (

{expense.group.name}

diff --git a/src/components/Expense/ExpenseList.tsx b/src/components/Expense/ExpenseList.tsx index f54b6e0d3..b593ad974 100644 --- a/src/components/Expense/ExpenseList.tsx +++ b/src/components/Expense/ExpenseList.tsx @@ -2,6 +2,7 @@ import { SplitType } from '@prisma/client'; import { type inferRouterOutputs } from '@trpc/server'; import Image from 'next/image'; import Link from 'next/link'; +import { useTranslation } from 'next-i18next'; import { useRouter } from 'next/router'; import React from 'react'; import { toast } from 'sonner'; @@ -111,8 +112,11 @@ const Expense: ExpenseComponent = ({ e, userId }) => {

{e.name}

- {displayName(e.paidByUser, userId)}{' '} - {t(`ui.expense.user.${e.amount < 0n ? 'received' : 'paid'}`)} {toUIString(e.amount)} + {t(e.amount < 0n ? 'expense_list.user_received' : 'expense_list.user_paid', { + actor: e.paidByUser.id === userId ? 'you' : 'other', + user: displayName(e.paidByUser), + amount: toUIString(e.amount), + })}

@@ -120,12 +124,9 @@ const Expense: ExpenseComponent = ({ e, userId }) => { {youPaid || 0n !== yourExpenseAmount ? ( <>
- {t('actors.you')} {t(`ui.expense.you.${youPaid ? 'lent' : 'owe'}`)} -
-
- {toUIString(yourExpenseAmount)} + {t(youPaid ? 'expense_list.you_lent' : 'expense_list.you_owe', { + amount: toUIString(yourExpenseAmount), + })}
) : ( @@ -154,9 +155,13 @@ const Settlement: ExpenseComponent = ({ e, userId }) => {

- {displayName(e.paidByUser, userId)}{' '} - {t(`ui.expense.user.${e.amount < 0n ? 'received' : 'paid'}`)} {toUIString(e.amount)}{' '} - {t('ui.expense.to')} {displayName(userDetails.data, userId)} + {t('expense_list.settlement', { + payerRole: e.paidByUser.id === userId ? 'you' : 'other', + receiverRole: receiverId === userId ? 'you' : 'other', + payer: displayName(e.paidByUser), + amount: toUIString(e.amount), + receiver: displayName(userDetails.data), + })}

@@ -192,16 +197,30 @@ const CurrencyConversion: ExpenseComponent = ({ e, userId }) => { }

- {t('ui.expense.for')} {displayName(e.paidByUser, userId)} {t('ui.and')}{' '} - {displayName(userDetails.data, userId)} + {t('expense_list.conversion_for_users', { + payerRole: e.paidByUser.id === userId ? 'you' : 'other', + receiverRole: receiverId === userId ? 'you' : 'other', + payer: displayName(e.paidByUser), + receiver: displayName(userDetails.data), + })}

); }; -const NoExpenses = () => ( -
- Empty -
-); +const NoExpenses = () => { + const { t } = useTranslation(); + + return ( +
+ {t('expense_details.empty_state_image_alt')} +
+ ); +}; diff --git a/src/components/Expense/Receipt.tsx b/src/components/Expense/Receipt.tsx index c0c2cba3b..77c6c74d1 100644 --- a/src/components/Expense/Receipt.tsx +++ b/src/components/Expense/Receipt.tsx @@ -1,6 +1,9 @@ +import { useTranslation } from 'next-i18next'; + import { AppDrawer } from '../ui/drawer'; export const Receipt = ({ fileKey }: { fileKey: string }) => { + const { t } = useTranslation(); const thumbKey = fileKey.replace('.webp', '-thumb.webp'); const thumbUrl = `/api/files/${thumbKey}`; const fullUrl = `/api/files/${fileKey}`; @@ -11,7 +14,7 @@ export const Receipt = ({ fileKey }: { fileKey: string }) => { // oxlint-disable-next-line next/no-img-element Expense receipt thumbnail { className="h-14 w-14 rounded-md object-cover object-center data-[loaded=false]:animate-pulse data-[loaded=false]:bg-gray-100/10" /> } - leftAction="Close" - title="Expense Receipt" + leftAction={t('actions.close')} + title={t('expense_details.receipt.title')} className="h-[98vh]" >
@@ -29,7 +32,7 @@ export const Receipt = ({ fileKey }: { fileKey: string }) => { src={fullUrl} width={300} height={800} - alt="Expense receipt" + alt={t('expense_details.receipt.image_alt')} data-loaded="false" onLoad={setDataLoaded} className="h-full w-full rounded-2xl object-cover data-[loaded=false]:animate-pulse data-[loaded=false]:bg-gray-100/10" diff --git a/src/components/Friend/CurrencyConversion.tsx b/src/components/Friend/CurrencyConversion.tsx index b58974a3d..0b4e0503e 100644 --- a/src/components/Friend/CurrencyConversion.tsx +++ b/src/components/Friend/CurrencyConversion.tsx @@ -199,7 +199,7 @@ export const CurrencyConversion: React.FC<{
{t('currency_conversion.rate')}
= ({ friendId, disabled }) => { + const { t } = useTranslation(); const router = useRouter(); const deleteFriendMutation = api.user.deleteFriend.useMutation(); @@ -22,7 +24,7 @@ export const DeleteFriend: React.FC<{ await deleteFriendMutation.mutateAsync({ friendId }); } catch (e) { console.error('Failed to delete friend', e); - toast.error('Failed to delete user'); + toast.error(t('errors.friend_deletion_failed')); return; } utils.expense.getBalances.invalidate().catch(console.error); @@ -32,11 +34,11 @@ export const DeleteFriend: React.FC<{ return ( = ({ 'Settlement', ]; - const { getCurrencyHelpersCached } = useTranslationWithUtils('common'); + const { getCurrencyHelpersCached, t } = useTranslationWithUtils('common_icu'); const exportToCSV = () => { const csvHeaders = headers.join(','); @@ -87,7 +87,7 @@ export const Export: React.FC = ({ return ( ); }; diff --git a/src/components/NotificationModal.tsx b/src/components/NotificationModal.tsx index 09934d8c9..35331351a 100644 --- a/src/components/NotificationModal.tsx +++ b/src/components/NotificationModal.tsx @@ -1,5 +1,6 @@ import { Bell } from 'lucide-react'; import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'next-i18next'; import { toast } from 'sonner'; import { useAppStore } from '~/store/appStore'; @@ -33,6 +34,7 @@ const NOTIFICATION_DISMISSED_TIME = 'notification_dismissed_time'; const NOTIFICATION_DISMISSED_TIME_THRESHOLD = 1000 * 60 * 60 * 24 * 30; // 14 days export const NotificationModal: React.FC = () => { + const { t } = useTranslation(); const updatePushSubscription = api.user.updatePushNotification.useMutation(); const webPushPublicKey = useAppStore((s) => s.webPushPublicKey); @@ -40,7 +42,7 @@ export const NotificationModal: React.FC = () => { useEffect(() => { if ('undefined' !== typeof window && 'serviceWorker' in navigator) { - // run only in browser + // Run only in browser navigator.serviceWorker.ready .then((reg) => { reg.pushManager @@ -66,7 +68,7 @@ export const NotificationModal: React.FC = () => { try { const result = await Notification.requestPermission(); if ('granted' === result) { - toast.success('You will receive notifications now'); + toast.success(t('notifications.permission_granted')); navigator.serviceWorker.ready .then(async (reg) => { if (!webPushPublicKey) { @@ -80,13 +82,13 @@ export const NotificationModal: React.FC = () => { updatePushSubscription.mutate({ subscription: JSON.stringify(sub) }); }) .catch((e) => { - toast.error('Cannot subscribe to notification'); + toast.error(t('errors.notification_subscribe_failed')); console.error(e); }); setModalOpen(false); } } catch (e) { - toast.error('Error requesting notification'); + toast.error(t('errors.notification_request_failed')); console.error(e); } } @@ -104,16 +106,14 @@ export const NotificationModal: React.FC = () => { - Enable notifications - - Don't miss on important events. Subscribe to get notification for added expenses - + {t('notifications.enable_title')} + {t('notifications.enable_description')} - Remind later + {t('actions.remind_later')} - Subscribe + {t('notifications.subscribe')} diff --git a/src/components/group/GroupMyBalance.tsx b/src/components/group/GroupMyBalance.tsx index 27d806389..4c3a903f0 100644 --- a/src/components/group/GroupMyBalance.tsx +++ b/src/components/group/GroupMyBalance.tsx @@ -22,47 +22,38 @@ const GroupMyBalance: React.FC = ({ const userMap = useMemo( () => - users.reduce< Record>( - (acc, user) => { - acc[user.id] = user; - return acc; - }, - {}, - ), + users.reduce>((acc, user) => { + acc[user.id] = user; + return acc; + }, {}), [users], ); const friendBalances = useMemo( () => - groupBalances.reduce< Record>>( - (acc, balance) => { - if (balance.userId === userId && 0 < BigMath.abs(balance.amount)) { - acc[balance.friendId] ??= {}; - const friendBalance = acc[balance.friendId]!; - friendBalance[balance.currency] = - (friendBalance[balance.currency] ?? 0n) + balance.amount; - } - return acc; - }, - {}, - ), + groupBalances.reduce>>((acc, balance) => { + if (balance.userId === userId && 0 < BigMath.abs(balance.amount)) { + acc[balance.friendId] ??= {}; + const friendBalance = acc[balance.friendId]!; + friendBalance[balance.currency] = + (friendBalance[balance.currency] ?? 0n) + balance.amount; + } + return acc; + }, {}), [groupBalances, userId], ); const cumulatedBalances = useMemo( () => Object.entries( - Object.values(friendBalances).reduce< Record>( - (acc, balances) => { - if (balances) { - Object.entries(balances).forEach(([currency, amount]) => { - acc[currency] = (acc[currency] ?? 0n) + amount; - }); - } - return acc; - }, - {}, - ), + Object.values(friendBalances).reduce>((acc, balances) => { + if (balances) { + Object.entries(balances).forEach(([currency, amount]) => { + acc[currency] = (acc[currency] ?? 0n) + amount; + }); + } + return acc; + }, {}), ).map(([currency, amount]) => ({ currency, amount })), [friendBalances], ); @@ -80,10 +71,15 @@ const GroupMyBalance: React.FC = ({
{Object.entries(balances).map(([currency, amount]) => (
- {0 < amount - ? `${friend?.name} ${t('ui.expense.user.owe')} ${t('actors.you_dativus').toLowerCase()}` - : `${t('actors.you')} ${t('ui.expense.you.owe')} ${friend?.name}`}{' '} - {getCurrencyHelpersCached(currency).toUIString(amount)} + {t( + 0 < amount + ? 'balance_summary.friend_owes_you' + : 'balance_summary.you_owe_friend', + { + friend: friend?.name, + amount: getCurrencyHelpersCached(currency).toUIString(amount), + }, + )}
))}
@@ -92,8 +88,7 @@ const GroupMyBalance: React.FC = ({ {2 < Object.keys(friendBalances).length ? (
- +{Object.keys(friendBalances).length - 2}{' '} - {Object.keys(friendBalances).length === 3 ? t('ui.balance') : t('ui.balances')}... + {t('balance_summary.more', { count: Object.keys(friendBalances).length - 2 })}
) : null}
diff --git a/src/hooks/useTranslationWithUtils.ts b/src/hooks/useTranslationWithUtils.ts index 1f462a0fa..2d8d3e760 100644 --- a/src/hooks/useTranslationWithUtils.ts +++ b/src/hooks/useTranslationWithUtils.ts @@ -25,7 +25,7 @@ export const useTranslationWithUtils = ( namespaces = [namespaces]; } if (!namespaces || namespaces.length === 0) { - namespaces = ['common']; + namespaces = ['common_icu']; } const translation = useTranslation(namespaces); diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index a682bc35b..181a8f3f2 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -16,9 +16,7 @@ import { useAddExpenseStore } from '~/store/addStore'; import { useAppStore } from '~/store/appStore'; import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; -import { registerICU } from '~/utils/i18n/registerICU'; - -registerICU(); +import { resolveSupportedLocale } from '~/utils/i18n/resolveLocale'; import 'react-easy-crop/react-easy-crop.css'; import '~/styles/globals.css'; @@ -126,47 +124,37 @@ const Auth: React.FC<{ Page: NextPageWithUser; pageProps: any }> = ({ Page, page useEffect(() => { if ('authenticated' === status && data.user) { - if (!data.user.preferredLanguage) { - // If user has no preferred language, set it to the current locale - const currentLocale = router.locale ?? 'en'; - - data.user.preferredLanguage = currentLocale; - updateUser - .mutateAsync({ - preferredLanguage: currentLocale, - }) - .then(() => - update({ - user: { - ...data.user, - preferredLanguage: currentLocale, - }, - }), + const currentLocale = resolveSupportedLocale( + router.locale, + i18nConfig.i18n.locales, + i18nConfig.i18n.defaultLocale, + ); + const preferredLanguage = data.user.preferredLanguage; + const resolvedLanguage = preferredLanguage + ? resolveSupportedLocale( + preferredLanguage, + i18nConfig.i18n.locales, + i18nConfig.i18n.defaultLocale, ) - .catch(console.error); - } else if (data.user.preferredLanguage === 'pt') { - // Fix for 'pt' preferred language to 'pt-PT' - data.user.preferredLanguage = 'pt-PT'; + : currentLocale; + + if (preferredLanguage !== resolvedLanguage) { updateUser .mutateAsync({ - preferredLanguage: 'pt-PT', + preferredLanguage: resolvedLanguage, }) .then(() => update({ user: { ...data.user, - preferredLanguage: 'pt-PT', + preferredLanguage: resolvedLanguage, }, }), ) .catch(console.error); - } else if (data.user.preferredLanguage && data.user.preferredLanguage !== router.locale) { - // Set user's preferred language by changing the locale + } else if (resolvedLanguage !== router.locale) { router - .push(router.asPath, router.asPath, { - locale: data.user.preferredLanguage, - scroll: false, - }) + .push(router.asPath, router.asPath, { locale: resolvedLanguage, scroll: false }) .catch(console.error); } } diff --git a/src/pages/account.tsx b/src/pages/account.tsx index 3f37ad261..aa5cf36e5 100644 --- a/src/pages/account.tsx +++ b/src/pages/account.tsx @@ -75,10 +75,10 @@ const AccountPage: NextPageWithUser<{ async (values: { name: string; image?: string | null; defaultCurrency?: string | null }) => { try { await updateDetailsMutation.mutateAsync(values); - toast.success(t('account.messages.submit_success'), { duration: 1500 }); + toast.success(t('account.messages.update_success'), { duration: 1500 }); utils.user.me.refetch().catch(console.error); } catch (error) { - toast.error(t('account.messages.submit_error')); + toast.error(t('account.messages.update_error')); console.error(error); } @@ -86,7 +86,10 @@ const AccountPage: NextPageWithUser<{ [updateDetailsMutation, utils.user.me, t], ); - const header = useMemo(() =>
Account
, []); + const header = useMemo( + () =>
{t('account.title')}
, + [t], + ); const onSignOut = useCallback(async () => { await signOut({ redirect: false }); @@ -201,7 +204,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => ({ feedbackPossible: Boolean(env.FEEDBACK_EMAIL), bankConnectionEnabled: Boolean(isBankConnectionConfigured()), bankConnection: whichBankConnectionConfigured(), - ...(await customServerSideTranslations(context.locale, ['common', 'currencies'])), + ...(await customServerSideTranslations(context.locale, ['common_icu', 'currencies'])), maxUploadFileSizeMB: env.UPLOAD_MAX_FILE_SIZE_MB, }, }); diff --git a/src/pages/activity.tsx b/src/pages/activity.tsx index 62e66c648..1165a4a38 100644 --- a/src/pages/activity.tsx +++ b/src/pages/activity.tsx @@ -31,9 +31,9 @@ function getPaymentString( } else if (isSettlement) { return (
- {t('actors.you')}{' '} - {user.id === paidBy ? t('ui.expense.you.paid') : t('ui.expense.you.received')}{' '} - {toUIString(amount)} + {t(user.id === paidBy ? 'activity.you_paid_amount' : 'activity.you_received_amount', { + amount: toUIString(amount), + })}
); } else { @@ -41,9 +41,14 @@ function getPaymentString(
- {t(`actors.you`)}{' '} - {t(`ui.expense.you.${(user.id === paidBy) !== amount < 0n ? 'lent' : 'owe'}`)}{' '} - {toUIString(expenseUserAmt)} + {t( + (user.id === paidBy) !== amount < 0n + ? 'activity.you_paid_amount' + : 'activity.you_received_amount', + { + amount: toUIString(expenseUserAmt), + }, + )}
); } @@ -93,24 +98,28 @@ const ActivityPage: NextPageWithUser = ({ user }) => {
{e.expense.deletedByUser ? (

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

) : (

- - {displayName(e.expense.paidByUser, user.id)} - {' '} {t( - `ui.expense.${e.expense.paidByUser.id === user.id ? 'you' : 'user'}.${e.expense.amount > 0n ? 'paid' : 'received'}`, - )}{' '} - {toUIString(e.expense.amount)} {t('ui.expense.for')}{' '} - {e.expense.name} + e.expense.paidByUser.id === user.id + ? e.expense.amount > 0n + ? 'activity.you_paid_for_expense' + : 'activity.you_received_for_expense' + : e.expense.amount > 0n + ? 'activity.user_paid_for_expense' + : 'activity.user_received_for_expense', + { + user: displayName(e.expense.paidByUser), + amount: toUIString(e.expense.amount), + expense: e.expense.name, + }, + )}

)} @@ -137,6 +146,6 @@ const ActivityPage: NextPageWithUser = ({ user }) => { ActivityPage.auth = true; -export const getStaticProps = withI18nStaticProps(['common']); +export const getStaticProps = withI18nStaticProps(['common_icu']); export default ActivityPage; diff --git a/src/pages/add.tsx b/src/pages/add.tsx index d26d049e7..df381929e 100644 --- a/src/pages/add.tsx +++ b/src/pages/add.tsx @@ -287,6 +287,10 @@ export const getServerSideProps: GetServerSideProps = async (context) => ({ enableSendingInvites: Boolean(env.ENABLE_SENDING_INVITES), bankConnectionEnabled: isBankConnectionConfigured(), maxUploadFileSizeMB: env.UPLOAD_MAX_FILE_SIZE_MB, - ...(await customServerSideTranslations(context.locale, ['common', 'categories', 'currencies'])), + ...(await customServerSideTranslations(context.locale, [ + 'common_icu', + 'categories', + 'currencies', + ])), }, }); diff --git a/src/pages/auth/signin.tsx b/src/pages/auth/signin.tsx index 260118156..5cda5639e 100644 --- a/src/pages/auth/signin.tsx +++ b/src/pages/auth/signin.tsx @@ -288,7 +288,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => { return { props: { - ...(await customServerSideTranslations(context.locale, ['common'])), + ...(await customServerSideTranslations(context.locale, ['common_icu'])), error: typeof error === 'string' ? error : '', feedbackEmail: env.FEEDBACK_EMAIL ?? '', providers: Object.values(providers ?? {}), diff --git a/src/pages/balances.tsx b/src/pages/balances.tsx index 34b1a74c7..3a99c4fbe 100644 --- a/src/pages/balances.tsx +++ b/src/pages/balances.tsx @@ -164,6 +164,6 @@ const CumulatedBalanceDisplay: React.FC<{ BalancePage.auth = true; -export const getStaticProps = withI18nStaticProps(['common']); +export const getStaticProps = withI18nStaticProps(['common_icu']); export default BalancePage; diff --git a/src/pages/balances/[friendId].tsx b/src/pages/balances/[friendId].tsx index a5f87a0d0..b8944d16c 100644 --- a/src/pages/balances/[friendId].tsx +++ b/src/pages/balances/[friendId].tsx @@ -216,7 +216,7 @@ FriendPage.auth = true; export const getServerSideProps: GetServerSideProps = async (context) => ({ props: { - ...(await customServerSideTranslations(context.locale, ['common'])), + ...(await customServerSideTranslations(context.locale, ['common_icu'])), }, }); diff --git a/src/pages/balances/[friendId]/expenses/[expenseId].tsx b/src/pages/balances/[friendId]/expenses/[expenseId].tsx index a1b7beaff..e88b48d22 100644 --- a/src/pages/balances/[friendId]/expenses/[expenseId].tsx +++ b/src/pages/balances/[friendId]/expenses/[expenseId].tsx @@ -118,7 +118,7 @@ ExpensesPage.auth = true; export const getServerSideProps: GetServerSideProps = async (context) => ({ props: { - ...(await customServerSideTranslations(context.locale, ['common'])), + ...(await customServerSideTranslations(context.locale, ['common_icu'])), }, }); diff --git a/src/pages/expenses/[expenseId].tsx b/src/pages/expenses/[expenseId].tsx index 4239c47a6..69027445c 100644 --- a/src/pages/expenses/[expenseId].tsx +++ b/src/pages/expenses/[expenseId].tsx @@ -124,7 +124,7 @@ ExpensesPage.auth = true; export const getServerSideProps: GetServerSideProps = async (context) => ({ props: { - ...(await customServerSideTranslations(context.locale, ['common'])), + ...(await customServerSideTranslations(context.locale, ['common_icu'])), }, }); diff --git a/src/pages/groups.tsx b/src/pages/groups.tsx index 79f08cbfa..d89c5a2cf 100644 --- a/src/pages/groups.tsx +++ b/src/pages/groups.tsx @@ -103,6 +103,6 @@ const BalancePage: NextPageWithUser = () => { BalancePage.auth = true; -export const getStaticProps = withI18nStaticProps(['common']); +export const getStaticProps = withI18nStaticProps(['common_icu']); export default BalancePage; diff --git a/src/pages/groups/[groupId].tsx b/src/pages/groups/[groupId].tsx index e4b824711..f7c3e8462 100644 --- a/src/pages/groups/[groupId].tsx +++ b/src/pages/groups/[groupId].tsx @@ -596,7 +596,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => { return { props: { - ...(await customServerSideTranslations(context.locale, ['common', 'currencies'])), + ...(await customServerSideTranslations(context.locale, ['common_icu', 'currencies'])), enableSendingInvites: env.ENABLE_SENDING_INVITES, }, }; diff --git a/src/pages/groups/[groupId]/expenses/[expenseId].tsx b/src/pages/groups/[groupId]/expenses/[expenseId].tsx index 2465290b3..0b5f4447c 100644 --- a/src/pages/groups/[groupId]/expenses/[expenseId].tsx +++ b/src/pages/groups/[groupId]/expenses/[expenseId].tsx @@ -120,7 +120,7 @@ ExpensesPage.auth = true; export const getServerSideProps: GetServerSideProps = async (context) => ({ props: { - ...(await customServerSideTranslations(context.locale, ['common'])), + ...(await customServerSideTranslations(context.locale, ['common_icu'])), }, }); diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 224afd9e0..3ebc5d0d8 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -49,10 +49,14 @@ const FeatureCard = ({ ); export default function Home() { - const { t } = useTranslation('home'); + const { t } = useTranslation('home_icu'); return ( <> + + {t('meta.title')} + +