From 403e66701bd7b5825fc2fd3388e5fdf7567c2eb5 Mon Sep 17 00:00:00 2001 From: Horus Lugo Date: Sat, 18 Apr 2026 18:23:18 +0200 Subject: [PATCH 1/5] feat(pwa): add debt transfer between parties Summary: - add a dedicated debt transfer flow from balance actions to move a user's debt into another joined party with same-currency filtering - create transfer helper logic, destination-party eligibility lookup, and name matching with exact auto-selection plus recommendations - add unit coverage, a Playwright journey, locale updates, and a changeset for the new user-facing workflow Rationale: - users already recreate these transfers manually, so the feature turns a repetitive multi-step workaround into a guided flow - keeping the implementation as paired expense creation matches the existing mental model and avoids extra transfer metadata complexity Tests: - pnpm test - pnpm lint - pnpm typecheck - pnpm -C packages/pwa exec playwright test e2e/debt-transfer.spec.ts - pnpm -C packages/pwa lingui:extract Co-authored-by: Codex --- .changeset/pink-rockets-try.md | 5 + packages/pwa/e2e/debt-transfer.spec.ts | 128 ++++++ packages/pwa/e2e/harness/scenarios.ts | 39 ++ packages/pwa/e2e/harness/trizum.fixture.ts | 65 ++- packages/pwa/e2e/pages/party.page.ts | 24 +- packages/pwa/e2e/pages/transfer-debt.page.ts | 68 +++ packages/pwa/locale/en/messages.po | 170 +++++-- packages/pwa/locale/es/messages.po | 170 +++++-- .../hooks/useEligibleDebtTransferParties.ts | 35 ++ packages/pwa/src/hooks/useParty.ts | 99 +++++ packages/pwa/src/lib/debtTransfer.test.ts | 130 ++++++ packages/pwa/src/lib/debtTransfer.ts | 176 ++++++++ packages/pwa/src/routeTree.gen.ts | 22 + packages/pwa/src/routes/party.$partyId.tsx | 36 +- .../routes/party_.$partyId.transfer-debt.tsx | 418 ++++++++++++++++++ 15 files changed, 1498 insertions(+), 87 deletions(-) create mode 100644 .changeset/pink-rockets-try.md create mode 100644 packages/pwa/e2e/debt-transfer.spec.ts create mode 100644 packages/pwa/e2e/pages/transfer-debt.page.ts create mode 100644 packages/pwa/src/hooks/useEligibleDebtTransferParties.ts create mode 100644 packages/pwa/src/lib/debtTransfer.test.ts create mode 100644 packages/pwa/src/lib/debtTransfer.ts create mode 100644 packages/pwa/src/routes/party_.$partyId.transfer-debt.tsx diff --git a/.changeset/pink-rockets-try.md b/.changeset/pink-rockets-try.md new file mode 100644 index 00000000..1477bb2c --- /dev/null +++ b/.changeset/pink-rockets-try.md @@ -0,0 +1,5 @@ +--- +"@trizum/pwa": minor +--- + +Add a debt transfer flow that lets users move their own debt from one party to another from the balances screen. diff --git a/packages/pwa/e2e/debt-transfer.spec.ts b/packages/pwa/e2e/debt-transfer.spec.ts new file mode 100644 index 00000000..4859a015 --- /dev/null +++ b/packages/pwa/e2e/debt-transfer.spec.ts @@ -0,0 +1,128 @@ +import { ExpensePage } from "./pages/expense.page"; +import { PartyPage } from "./pages/party.page"; +import { TransferDebtPage } from "./pages/transfer-debt.page"; +import { + createDebtTransferDestinationFixture, + createSettlementPartyFixture, + debtTransferJourney, + defaultParticipants, +} from "./harness/scenarios"; +import { expect, test } from "./harness/trizum.fixture"; + +test.describe("Debt transfer", () => { + test("moves a debt from one joined party to another through balances", async ({ + harness, + page, + }) => { + const expensePage = new ExpensePage(page); + const originPartyPage = new PartyPage(page); + const destinationPartyPage = new PartyPage(page); + const transferDebtPage = new TransferDebtPage(page); + const originAction = { + actionLabel: "Pay" as const, + fromLabel: `${defaultParticipants.blair.name} (me)`, + toLabel: defaultParticipants.alex.name, + }; + const destinationAction = { + actionLabel: "Pay" as const, + fromLabel: `${debtTransferJourney.destinationMemberParticipant.name} (me)`, + toLabel: debtTransferJourney.destinationCreditorParticipant.name, + }; + + const [originParty, destinationParty] = await test.step( + "seed both parties in one browser boot", + async () => + harness.seedParties([ + createSettlementPartyFixture(), + createDebtTransferDestinationFixture(), + ]), + ); + + await test.step("join both parties in the local party list", async () => { + await harness.seedPartyList({ + username: "Harness User", + phone: "", + parties: { + [originParty.partyId]: true, + [destinationParty.partyId]: true, + }, + participantInParties: { + [originParty.partyId]: debtTransferJourney.originMemberParticipantId, + [destinationParty.partyId]: + debtTransferJourney.destinationMemberParticipant.id, + }, + }); + }); + + await test.step( + "open balances in the origin party and confirm the transfer action is available", + async () => { + await harness.navigate(`/party/${originParty.partyId}?tab=balances`); + await originPartyPage.expectLoaded( + originParty.partyId, + debtTransferJourney.originPartyName, + ); + await originPartyPage.expectSettlementActionVisible(originAction); + await originPartyPage.expectSettlementActionButtonVisible( + originAction, + "Transfer debt", + ); + }, + ); + + await test.step( + "pick the destination party and use the recommended creditor match", + async () => { + await originPartyPage.openSettlementActionButton( + originAction, + "Transfer debt", + ); + + await transferDebtPage.expectLoaded(); + await transferDebtPage.expectSearchParams({ + amount: "3000", + fromId: defaultParticipants.blair.id, + toId: defaultParticipants.alex.id, + }); + await transferDebtPage.chooseDestinationParty( + debtTransferJourney.destinationPartyName, + ); + await transferDebtPage.expectRecommendation( + debtTransferJourney.destinationCreditorParticipant.name, + ); + await transferDebtPage.chooseRecommendation( + debtTransferJourney.destinationCreditorParticipant.name, + ); + }, + ); + + await test.step( + "complete the transfer and settle the origin party balance", + async () => { + await transferDebtPage.completeTransfer(); + await expensePage.expectLoaded("Debt transfer to another party"); + + await page.goBack(); + await expect(page).toHaveURL( + new RegExp(`/party/${originParty.partyId}\\?tab=balances(?:&.*)?$`), + ); + await originPartyPage.expectSettlementActionRemoved(originAction); + await originPartyPage.expectFullySettled(); + }, + ); + + await test.step( + "show the transferred debt as a new balance action in the destination party", + async () => { + await harness.navigate(`/party/${destinationParty.partyId}?tab=balances`); + await destinationPartyPage.expectLoaded( + destinationParty.partyId, + debtTransferJourney.destinationPartyName, + ); + await destinationPartyPage.expectSettlementActionVisible( + destinationAction, + ); + }, + ); + }); +}); diff --git a/packages/pwa/e2e/harness/scenarios.ts b/packages/pwa/e2e/harness/scenarios.ts index 777e34dd..3f127da2 100644 --- a/packages/pwa/e2e/harness/scenarios.ts +++ b/packages/pwa/e2e/harness/scenarios.ts @@ -37,6 +37,20 @@ export const expenseLogJourney = { newExpenseTitle: "Late checkout snacks", } as const; +export const debtTransferJourney = { + originPartyName: "Weekend trip", + destinationPartyName: "City break", + originMemberParticipantId: defaultParticipants.blair.id, + destinationMemberParticipant: { + id: "participant-blair-city", + name: "Blair Downtown", + }, + destinationCreditorParticipant: { + id: "participant-alex-smith", + name: "Alex Smith", + }, +} as const; + export function createPartyFixture() { return { party: { @@ -152,3 +166,28 @@ export function createExpenseLogFixture( })), }; } + +export function createDebtTransferDestinationFixture() { + return { + party: { + type: "party" as const, + name: debtTransferJourney.destinationPartyName, + symbol: "🌆", + description: "Costs for the city break.", + currency: "EUR" as const, + participants: { + [debtTransferJourney.destinationMemberParticipant.id]: { + ...debtTransferJourney.destinationMemberParticipant, + }, + [debtTransferJourney.destinationCreditorParticipant.id]: { + ...debtTransferJourney.destinationCreditorParticipant, + }, + [defaultParticipants.casey.id]: { + ...defaultParticipants.casey, + }, + }, + }, + expenses: [], + photos: [], + }; +} diff --git a/packages/pwa/e2e/harness/trizum.fixture.ts b/packages/pwa/e2e/harness/trizum.fixture.ts index 85a90c44..444e5e4e 100644 --- a/packages/pwa/e2e/harness/trizum.fixture.ts +++ b/packages/pwa/e2e/harness/trizum.fixture.ts @@ -62,6 +62,13 @@ export interface BrowserHarness { joinUrl: string; partyId: string; }>; + seedParties(fixtures: unknown[]): Promise< + { + joinCode: string; + joinUrl: string; + partyId: string; + }[] + >; seedPartyList(seed: PartyListSeed): Promise<{ partyListId: string; }>; @@ -91,16 +98,28 @@ function createOfflinePath(pathname = "/") { } function createBrowserHarness(page: Page): BrowserHarness { + async function hasInternalHooks() { + return page + .evaluate(() => { + const internalWindow = window as Partial; + return ( + typeof internalWindow.__internal_createPartyFromMigrationData === + "function" && + typeof internalWindow.__internal_seedPartyListState === "function" && + typeof internalWindow.__internal_readPartyListState === "function" + ); + }) + .catch(() => false); + } + async function goto(path = "/") { await page.goto(createOfflinePath(path)); } async function navigate(path: string) { - const nextPath = createOfflinePath(path).replace( - /\?__internal_offline_only=true$/, - "", - ); - const nextUrl = new URL(nextPath, "http://trizum.local"); + const nextUrl = new URL(createOfflinePath(path), "http://trizum.local"); + nextUrl.searchParams.delete("__internal_offline_only"); + const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`; await page.evaluate((targetPath) => { const url = new URL(targetPath, window.location.origin); @@ -130,22 +149,16 @@ function createBrowserHarness(page: Page): BrowserHarness { async function waitForInternalHooks() { await expect .poll(async () => { - return page.evaluate(() => { - const internalWindow = window as Partial; - return ( - typeof internalWindow.__internal_createPartyFromMigrationData === - "function" && - typeof internalWindow.__internal_seedPartyListState === - "function" && - typeof internalWindow.__internal_readPartyListState === "function" - ); - }); + return hasInternalHooks(); }) .toBe(true); } async function bootstrapForSeeding() { - await goto("/"); + if (!(await hasInternalHooks())) { + await goto("/"); + } + await waitForInternalHooks(); } @@ -168,6 +181,25 @@ function createBrowserHarness(page: Page): BrowserHarness { return buildPartySeedResult(partyId); } + async function seedParties(fixtures: unknown[]) { + await bootstrapForSeeding(); + + const partyIds = await page.evaluate(async (partyFixtures) => { + const internalWindow = window as InternalHarnessWindow; + const nextPartyIds: string[] = []; + + for (const fixture of partyFixtures) { + nextPartyIds.push( + await internalWindow.__internal_createPartyFromMigrationData(fixture), + ); + } + + return nextPartyIds; + }, fixtures); + + return partyIds.map(buildPartySeedResult); + } + async function seedPartyList(seed: PartyListSeed) { await bootstrapForSeeding(); return writePartyList(seed); @@ -262,6 +294,7 @@ function createBrowserHarness(page: Page): BrowserHarness { navigate, gotoParty, seedParty, + seedParties, seedPartyList, seedJoinableParty, joinSeededParty, diff --git a/packages/pwa/e2e/pages/party.page.ts b/packages/pwa/e2e/pages/party.page.ts index 85b9ffca..460d628d 100644 --- a/packages/pwa/e2e/pages/party.page.ts +++ b/packages/pwa/e2e/pages/party.page.ts @@ -128,12 +128,32 @@ export class PartyPage { ).toBeVisible(); } + async expectSettlementActionButtonVisible( + action: SettlementAction, + buttonName: string, + ) { + await expect( + this.settlementActionCard(action).getByRole("button", { + name: buttonName, + }), + ).toBeVisible(); + } + async openSettlementAction(action: SettlementAction) { await this.settlementActionCard(action) .getByRole("button", { name: action.actionLabel }) .click(); } + async openSettlementActionButton( + action: SettlementAction, + buttonName: string, + ) { + await this.settlementActionCard(action) + .getByRole("button", { name: buttonName }) + .click(); + } + async expectSettlementActionRemoved(action: SettlementAction) { await expect(this.settlementActionCard(action)).toHaveCount(0); } @@ -143,7 +163,9 @@ export class PartyPage { await expect(this.debtFreeMessage).toBeVisible(); await expect(this.nobodyOwesYouMessage).toBeVisible(); await expect( - this.page.getByRole("button", { name: /^(Pay|Mark as paid)$/ }), + this.page.getByRole("button", { + name: /^(Pay|Mark as paid|Transfer debt)$/, + }), ).toHaveCount(0); } diff --git a/packages/pwa/e2e/pages/transfer-debt.page.ts b/packages/pwa/e2e/pages/transfer-debt.page.ts new file mode 100644 index 00000000..b6ef6555 --- /dev/null +++ b/packages/pwa/e2e/pages/transfer-debt.page.ts @@ -0,0 +1,68 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +export class TransferDebtPage { + readonly page: Page; + readonly transferDebtButton: Locator; + readonly destinationPartySelect: Locator; + readonly recommendationsSection: Locator; + + constructor(page: Page) { + this.page = page; + this.transferDebtButton = page.getByRole("button", { + name: "Transfer debt", + }); + this.destinationPartySelect = page.getByRole("button", { + name: "Destination party", + }); + this.recommendationsSection = page + .locator("div.rounded-xl") + .filter({ hasText: "Recommendations" }); + } + + async expectLoaded() { + await expect(this.page).toHaveURL(/\/party\/[^/]+\/transfer-debt\?.+/); + await expect( + this.page.getByRole("heading", { exact: true, name: "Transfer debt" }), + ).toBeVisible(); + await expect(this.destinationPartySelect).toBeVisible(); + await expect(this.transferDebtButton).toBeVisible(); + } + + async expectSearchParams(params: { + amount: string; + fromId: string; + toId: string; + }) { + await expect + .poll(() => { + const url = new URL(this.page.url()); + return { + amount: url.searchParams.get("amount"), + fromId: url.searchParams.get("fromId"), + toId: url.searchParams.get("toId"), + }; + }) + .toEqual(params); + } + + async chooseDestinationParty(partyName: string) { + await this.destinationPartySelect.click(); + await this.page.getByRole("option", { name: partyName }).click(); + } + + async expectRecommendation(participantName: string) { + await expect( + this.recommendationsSection.getByRole("button", { name: participantName }), + ).toBeVisible(); + } + + async chooseRecommendation(participantName: string) { + await this.recommendationsSection + .getByRole("button", { name: participantName }) + .click(); + } + + async completeTransfer() { + await this.transferDebtButton.click(); + } +} diff --git a/packages/pwa/locale/en/messages.po b/packages/pwa/locale/en/messages.po index c9c1f7e7..c012c322 100644 --- a/packages/pwa/locale/en/messages.po +++ b/packages/pwa/locale/en/messages.po @@ -15,12 +15,12 @@ msgstr "" #: src/routes/party_.$partyId.pay.tsx:101 #: src/routes/party_.$partyId.pay.tsx:107 -#: src/routes/party.$partyId.tsx:973 -#: src/routes/party.$partyId.tsx:979 +#: src/routes/party.$partyId.tsx:983 +#: src/routes/party.$partyId.tsx:989 msgid "(me)" msgstr "(me)" -#: src/routes/party.$partyId.tsx:442 +#: src/routes/party.$partyId.tsx:443 msgid "[DEV] Create expenses" msgstr "[DEV] Create expenses" @@ -82,8 +82,8 @@ msgstr "ADB Unit of Account" msgid "Add" msgstr "Add" -#: src/routes/party.$partyId.tsx:459 -#: src/routes/party.$partyId.tsx:467 +#: src/routes/party.$partyId.tsx:460 +#: src/routes/party.$partyId.tsx:468 msgid "Add an expense" msgstr "Add an expense" @@ -92,7 +92,7 @@ msgid "Add expenses to this party to unlock totals, rankings, and individual spe msgstr "Add expenses to this party to unlock totals, rankings, and individual spend." #: src/routes/index.tsx:231 -#: src/routes/party.$partyId.tsx:425 +#: src/routes/party.$partyId.tsx:426 msgid "Add or create" msgstr "Add or create" @@ -259,15 +259,15 @@ msgstr "Bahamian Dollar" msgid "Bahraini Dinar" msgstr "Bahraini Dinar" -#: src/routes/party.$partyId.tsx:211 +#: src/routes/party.$partyId.tsx:212 msgid "Balance, Highest First" msgstr "Balance, Highest First" -#: src/routes/party.$partyId.tsx:193 +#: src/routes/party.$partyId.tsx:194 msgid "Balance, Lowest First" msgstr "Balance, Lowest First" -#: src/routes/party.$partyId.tsx:363 +#: src/routes/party.$partyId.tsx:364 msgid "Balances" msgstr "Balances" @@ -427,6 +427,14 @@ msgstr "Chinese Yuan" msgid "Choose the currency for expenses in this party" msgstr "Choose the currency for expenses in this party" +#: src/routes/party_.$partyId.transfer-debt.tsx:250 +msgid "Choose the participant who should receive the transferred debt" +msgstr "Choose the participant who should receive the transferred debt" + +#: src/routes/party_.$partyId.transfer-debt.tsx:222 +msgid "Choose the party where this debt should continue" +msgstr "Choose the party where this debt should continue" + #: src/components/CalculatorToolbar.tsx:449 msgid "Clear all" msgstr "Clear all" @@ -572,6 +580,18 @@ msgstr "Debt settled between {0} and {1}!" msgid "Debt settled between {fromName} and {toName}!" msgstr "Debt settled between {fromName} and {toName}!" +#: src/routes/party_.$partyId.transfer-debt.tsx:182 +msgid "Debt transfer from another party" +msgstr "Debt transfer from another party" + +#: src/routes/party_.$partyId.transfer-debt.tsx:181 +msgid "Debt transfer to another party" +msgstr "Debt transfer to another party" + +#: src/routes/party_.$partyId.transfer-debt.tsx:187 +msgid "Debt transferred" +msgstr "Debt transferred" + #: src/components/CalculatorToolbar.tsx:554 msgid "Decimal point" msgstr "Decimal point" @@ -589,6 +609,10 @@ msgstr "Description" msgid "Description must be less than 500 characters" msgstr "Description must be less than 500 characters" +#: src/routes/party_.$partyId.transfer-debt.tsx:221 +msgid "Destination party" +msgstr "Destination party" + #: src/components/PartyPendingComponent.tsx:99 msgid "Did you know?" msgstr "Did you know?" @@ -711,7 +735,7 @@ msgstr "Expense Split Mode" msgid "Expense updated" msgstr "Expense updated" -#: src/routes/party.$partyId.tsx:357 +#: src/routes/party.$partyId.tsx:358 msgid "Expenses" msgstr "Expenses" @@ -751,6 +775,10 @@ msgstr "Failed to load licenses. Please try again later." msgid "Failed to mark expense as paid" msgstr "Failed to mark expense as paid" +#: src/routes/party_.$partyId.transfer-debt.tsx:188 +msgid "Failed to transfer debt" +msgstr "Failed to transfer debt" + #: src/routes/party_.$partyId.expense.$expenseId_.edit.tsx:171 msgid "Failed to update expense" msgstr "Failed to update expense" @@ -823,6 +851,10 @@ msgstr "Go back" msgid "Go Back" msgstr "Go Back" +#: src/routes/party_.$partyId.transfer-debt.tsx:121 +msgid "Go back and try again from the balances list." +msgstr "Go back and try again from the balances list." + #: src/routes/migrate_.tricount.tsx:315 msgid "Go back to home" msgstr "Go back to home" @@ -859,7 +891,7 @@ msgstr "Have an idea to improve trizum? We'd love to hear it." msgid "Heads up!" msgstr "Heads up!" -#: src/routes/party.$partyId.tsx:772 +#: src/routes/party.$partyId.tsx:775 msgid "Here is a list of operations you and other party members can do to balance your position." msgstr "Here is a list of operations you and other party members can do to balance your position." @@ -892,7 +924,7 @@ msgstr "How do you want to call this party?" msgid "How does offline sync work?" msgstr "How does offline sync work?" -#: src/routes/party.$partyId.tsx:768 +#: src/routes/party.$partyId.tsx:771 msgid "How should I balance?" msgstr "How should I balance?" @@ -916,7 +948,7 @@ msgstr "ID is required" msgid "Image must be smaller than 10MB" msgstr "Image must be smaller than 10MB" -#: src/routes/party.$partyId.tsx:660 +#: src/routes/party.$partyId.tsx:661 msgid "Impact on my balance: <0/>" msgstr "Impact on my balance: <0/>" @@ -1063,7 +1095,7 @@ msgstr "Last used" msgid "Last year" msgstr "Last year" -#: src/routes/party.$partyId.tsx:341 +#: src/routes/party.$partyId.tsx:342 msgid "Leave party" msgstr "Leave party" @@ -1137,7 +1169,7 @@ msgstr "Manage the participants list. Existing members can only be archived." #: src/routes/party_.$partyId.pay.tsx:93 #: src/routes/party_.$partyId.pay.tsx:127 -#: src/routes/party.$partyId.tsx:1010 +#: src/routes/party.$partyId.tsx:1020 msgid "Mark as paid" msgstr "Mark as paid" @@ -1163,7 +1195,7 @@ msgstr "Me" #: src/components/ExpenseEditor.tsx:268 #: src/routes/index.tsx:117 #: src/routes/party_.$partyId.expense.$expenseId.tsx:103 -#: src/routes/party.$partyId.tsx:225 +#: src/routes/party.$partyId.tsx:226 msgid "Menu" msgstr "Menu" @@ -1190,6 +1222,7 @@ msgid "Migration successful" msgstr "Migration successful" #: src/routes/party_.$partyId.pay.tsx:24 +#: src/routes/party_.$partyId.transfer-debt.tsx:39 msgid "Missing search params" msgstr "Missing search params" @@ -1231,7 +1264,7 @@ msgstr "Myanma Kyat" #: src/routes/new.tsx:186 #: src/routes/party_.$partyId.settings.tsx:166 -#: src/routes/party.$partyId.tsx:175 +#: src/routes/party.$partyId.tsx:176 msgid "Name" msgstr "Name" @@ -1304,6 +1337,10 @@ msgstr "No camera found on this device" msgid "No currency" msgstr "No currency" +#: src/routes/party_.$partyId.transfer-debt.tsx:215 +msgid "No destination party available" +msgstr "No destination party available" + #: src/components/EmojiPicker.tsx:333 msgid "No emoji found" msgstr "No emoji found" @@ -1325,7 +1362,11 @@ msgstr "No spending stats yet" msgid "No updates available" msgstr "No updates available" -#: src/routes/party.$partyId.tsx:854 +#: src/routes/party_.$partyId.transfer-debt.tsx:291 +msgid "Nobody else is available in this party" +msgstr "Nobody else is available in this party" + +#: src/routes/party.$partyId.tsx:858 msgid "Nobody owes you money!" msgstr "Nobody owes you money!" @@ -1365,10 +1406,14 @@ msgstr "Older parties stay tucked away, not gone" msgid "Omani Rial" msgstr "Omani Rial" -#: src/routes/party.$partyId.tsx:266 +#: src/routes/party.$partyId.tsx:267 msgid "Only show your expenses" msgstr "Only show your expenses" +#: src/routes/party_.$partyId.transfer-debt.tsx:131 +msgid "Only your own debt can be transferred" +msgstr "Only your own debt can be transferred" + #: src/routes/index.tsx:377 msgid "Open archived parties" msgstr "Open archived parties" @@ -1401,12 +1446,13 @@ msgstr "Optional description for this expense" msgid "or enter code" msgstr "or enter code" -#: src/routes/party.$partyId.tsx:870 +#: src/routes/party.$partyId.tsx:874 msgid "Other operations" msgstr "Other operations" #: src/routes/party_.$partyId.pay.tsx:104 -#: src/routes/party.$partyId.tsx:976 +#: src/routes/party_.$partyId.transfer-debt.tsx:380 +#: src/routes/party.$partyId.tsx:986 msgid "owes" msgstr "owes" @@ -1532,15 +1578,15 @@ msgid "Paste the Tricount sharing message, URL (e.g., https://tricount.com/abc12 msgstr "Paste the Tricount sharing message, URL (e.g., https://tricount.com/abc123), or the direct key" #: src/routes/party_.$partyId.pay.tsx:93 -#: src/routes/party.$partyId.tsx:1010 +#: src/routes/party.$partyId.tsx:1020 msgid "Pay" msgstr "Pay" -#: src/routes/party.$partyId.tsx:831 +#: src/routes/party.$partyId.tsx:835 msgid "People that owe you money" msgstr "People that owe you money" -#: src/routes/party.$partyId.tsx:262 +#: src/routes/party.$partyId.tsx:263 msgid "Personal mode" msgstr "Personal mode" @@ -1644,6 +1690,10 @@ msgstr "Real-Time Sync" msgid "Receipt Attachments" msgstr "Receipt Attachments" +#: src/routes/party_.$partyId.transfer-debt.tsx:268 +msgid "Recommendations" +msgstr "Recommendations" + #: src/components/UpdateController.tsx:28 msgid "Reload" msgstr "Reload" @@ -1735,7 +1785,7 @@ msgstr "Search emoji" msgid "Search emoji..." msgstr "Search emoji..." -#: src/routes/party.$partyId.tsx:294 +#: src/routes/party.$partyId.tsx:295 msgid "See spending totals and rankings" msgstr "See spending totals and rankings" @@ -1756,7 +1806,7 @@ msgid "Set up your username, avatar, and preferences" msgstr "Set up your username, avatar, and preferences" #: src/routes/index.tsx:133 -#: src/routes/party.$partyId.tsx:329 +#: src/routes/party.$partyId.tsx:330 #: src/routes/settings.tsx:91 msgid "Settings" msgstr "Settings" @@ -1771,7 +1821,7 @@ msgstr "Seychellois Rupee" #: src/routes/party_.$partyId.share.tsx:58 #: src/routes/party_.$partyId.share.tsx:93 -#: src/routes/party.$partyId.tsx:312 +#: src/routes/party.$partyId.tsx:313 msgid "Share party" msgstr "Share party" @@ -1827,7 +1877,7 @@ msgstr "Somali Shilling" msgid "Something went wrong" msgstr "Something went wrong" -#: src/routes/party.$partyId.tsx:162 +#: src/routes/party.$partyId.tsx:163 msgid "Sort balances" msgstr "Sort balances" @@ -1872,7 +1922,7 @@ msgstr "Start date" msgid "Start tracking expenses with your group" msgstr "Start tracking expenses with your group" -#: src/routes/party.$partyId.tsx:290 +#: src/routes/party.$partyId.tsx:291 msgid "Stats" msgstr "Stats" @@ -1972,7 +2022,7 @@ msgstr "Take photo" msgid "Tanzanian Shilling" msgstr "Tanzanian Shilling" -#: src/routes/party.$partyId.tsx:248 +#: src/routes/party.$partyId.tsx:249 msgid "Tap to change" msgstr "Tap to change" @@ -2010,6 +2060,10 @@ msgstr "Third-Party Licenses" msgid "This application uses the following open source libraries and tools. Below are their licenses and attributions." msgstr "This application uses the following open source libraries and tools. Below are their licenses and attributions." +#: src/routes/party_.$partyId.transfer-debt.tsx:120 +msgid "This debt is no longer available" +msgstr "This debt is no longer available" + #: src/hooks/useMediaFileActions.ts:34 msgid "This HEIC or HEIF image could not be processed. Try another photo or export it as JPEG or PNG." msgstr "This HEIC or HEIF image could not be processed. Try another photo or export it as JPEG or PNG." @@ -2018,10 +2072,22 @@ msgstr "This HEIC or HEIF image could not be processed. Try another photo or exp msgid "This isn't a valid ID" msgstr "This isn't a valid ID" +#: src/routes/party_.$partyId.transfer-debt.tsx:292 +msgid "This party needs another active participant besides you to receive the transferred debt." +msgstr "This party needs another active participant besides you to receive the transferred debt." + #: src/routes/about.tsx:144 msgid "This project uses various open source libraries and tools." msgstr "This project uses various open source libraries and tools." +#: src/routes/party_.$partyId.transfer-debt.tsx:296 +msgid "This will settle the debt in <0>{0} and create the same debt in <1>{1}, where <2>{2} is owed by <3>{3}." +msgstr "This will settle the debt in <0>{0} and create the same debt in <1>{1}, where <2>{2} is owed by <3>{3}." + +#: src/routes/party_.$partyId.transfer-debt.tsx:306 +msgid "This will settle the debt in <0>{originPartyName} and create the same debt in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} is owed by <3>{destinationCurrentParticipantName}." +msgstr "This will settle the debt in <0>{originPartyName} and create the same debt in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} is owed by <3>{destinationCurrentParticipantName}." + #. Label for the timeframe filter on the party stats screen #: src/components/PartyStatsView.tsx:270 msgid "Timeframe" @@ -2067,6 +2133,18 @@ msgstr "Total spent" msgid "Total split:" msgstr "Total split:" +#: src/routes/party_.$partyId.transfer-debt.tsx:118 +#: src/routes/party_.$partyId.transfer-debt.tsx:129 +#: src/routes/party_.$partyId.transfer-debt.tsx:204 +#: src/routes/party_.$partyId.transfer-debt.tsx:333 +#: src/routes/party.$partyId.tsx:1041 +msgid "Transfer debt" +msgstr "Transfer debt" + +#: src/routes/party_.$partyId.transfer-debt.tsx:186 +msgid "Transferring debt..." +msgstr "Transferring debt..." + #: src/routes/migrate_.tricount.tsx:218 msgid "Tricount key" msgstr "Tricount key" @@ -2248,7 +2326,7 @@ msgstr "View Third-Party Licenses" msgid "Viewing as {0}" msgstr "Viewing as {0}" -#: src/routes/party.$partyId.tsx:244 +#: src/routes/party.$partyId.tsx:245 msgid "Viewing as {participantName}" msgstr "Viewing as {participantName}" @@ -2273,6 +2351,10 @@ msgstr "Welcome to trizum" msgid "What is this party about?" msgstr "What is this party about?" +#: src/routes/party_.$partyId.transfer-debt.tsx:301 +msgid "What will happen" +msgstr "What will happen" + #: src/routes/settings.tsx:150 msgid "What's your preferred way to be addressed?" msgstr "What's your preferred way to be addressed?" @@ -2281,6 +2363,14 @@ msgstr "What's your preferred way to be addressed?" msgid "Who are you?" msgstr "Who are you?" +#: src/routes/party_.$partyId.transfer-debt.tsx:239 +msgid "Who is {0} in this party?" +msgstr "Who is {0} in this party?" + +#: src/routes/party_.$partyId.transfer-debt.tsx:249 +msgid "Who is {sourceCreditorName} in this party?" +msgstr "Who is {sourceCreditorName} in this party?" + #: src/routes/new.tsx:279 msgid "Who is invited to this party? You can add more participants later." msgstr "Who is invited to this party? You can add more participants later." @@ -2309,19 +2399,31 @@ msgstr "Yemeni Rial" msgid "Yes! Your data is stored locally on your device and only synced with people you explicitly share your groups with. We don't have access to your expense data." msgstr "Yes! Your data is stored locally on your device and only synced with people you explicitly share your groups with. We don't have access to your expense data." +#: src/routes/party_.$partyId.transfer-debt.tsx:239 +msgid "You are" +msgstr "You are" + #: src/components/PartyPendingComponent.tsx:16 msgid "You can add photos to your expenses for better tracking." msgstr "You can add photos to your expenses for better tracking." -#: src/routes/party.$partyId.tsx:127 +#: src/routes/party_.$partyId.transfer-debt.tsx:132 +msgid "You can only transfer debt from actions where you are the one who owes the money." +msgstr "You can only transfer debt from actions where you are the one who owes the money." + +#: src/routes/party.$partyId.tsx:128 msgid "You left the party!" msgstr "You left the party!" -#: src/routes/party.$partyId.tsx:792 +#: src/routes/party_.$partyId.transfer-debt.tsx:216 +msgid "You need another active party with the same currency to transfer this debt." +msgstr "You need another active party with the same currency to transfer this debt." + +#: src/routes/party.$partyId.tsx:795 msgid "You owe money to people" msgstr "You owe money to people" -#: src/routes/party.$partyId.tsx:815 +#: src/routes/party.$partyId.tsx:819 msgid "You're debt free!" msgstr "You're debt free!" diff --git a/packages/pwa/locale/es/messages.po b/packages/pwa/locale/es/messages.po index 76c71f92..52af0c54 100644 --- a/packages/pwa/locale/es/messages.po +++ b/packages/pwa/locale/es/messages.po @@ -15,12 +15,12 @@ msgstr "" #: src/routes/party_.$partyId.pay.tsx:101 #: src/routes/party_.$partyId.pay.tsx:107 -#: src/routes/party.$partyId.tsx:973 -#: src/routes/party.$partyId.tsx:979 +#: src/routes/party.$partyId.tsx:983 +#: src/routes/party.$partyId.tsx:989 msgid "(me)" msgstr "(yo)" -#: src/routes/party.$partyId.tsx:442 +#: src/routes/party.$partyId.tsx:443 msgid "[DEV] Create expenses" msgstr "[DEV] Crear gastos" @@ -78,8 +78,8 @@ msgstr "Unidad de Cuenta del BAsD" msgid "Add" msgstr "Sumar" -#: src/routes/party.$partyId.tsx:459 -#: src/routes/party.$partyId.tsx:467 +#: src/routes/party.$partyId.tsx:460 +#: src/routes/party.$partyId.tsx:468 msgid "Add an expense" msgstr "Añadir un gasto" @@ -88,7 +88,7 @@ msgid "Add expenses to this party to unlock totals, rankings, and individual spe msgstr "Añade gastos a este grupo para desbloquear totales, rankings y gasto individual." #: src/routes/index.tsx:231 -#: src/routes/party.$partyId.tsx:425 +#: src/routes/party.$partyId.tsx:426 msgid "Add or create" msgstr "Añadir o crear" @@ -247,15 +247,15 @@ msgstr "Dólar Bahameño" msgid "Bahraini Dinar" msgstr "Dinar Bareiní" -#: src/routes/party.$partyId.tsx:211 +#: src/routes/party.$partyId.tsx:212 msgid "Balance, Highest First" msgstr "Balance, Mayor Primero" -#: src/routes/party.$partyId.tsx:193 +#: src/routes/party.$partyId.tsx:194 msgid "Balance, Lowest First" msgstr "Balance, Menor Primero" -#: src/routes/party.$partyId.tsx:363 +#: src/routes/party.$partyId.tsx:364 msgid "Balances" msgstr "Balance" @@ -403,6 +403,14 @@ msgstr "Yuan Chino" msgid "Choose the currency for expenses in this party" msgstr "Elige la moneda para los gastos de este grupo" +#: src/routes/party_.$partyId.transfer-debt.tsx:250 +msgid "Choose the participant who should receive the transferred debt" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:222 +msgid "Choose the party where this debt should continue" +msgstr "" + #: src/components/CalculatorToolbar.tsx:449 msgid "Clear all" msgstr "Borrar todo" @@ -544,6 +552,18 @@ msgstr "¡Deuda saldada entre {0} y {1}!" msgid "Debt settled between {fromName} and {toName}!" msgstr "¡Deuda saldada entre {fromName} y {toName}!" +#: src/routes/party_.$partyId.transfer-debt.tsx:182 +msgid "Debt transfer from another party" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:181 +msgid "Debt transfer to another party" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:187 +msgid "Debt transferred" +msgstr "" + #: src/components/CalculatorToolbar.tsx:554 msgid "Decimal point" msgstr "Punto decimal" @@ -561,6 +581,10 @@ msgstr "Descripción" msgid "Description must be less than 500 characters" msgstr "La descripción debe tener menos de 500 caracteres" +#: src/routes/party_.$partyId.transfer-debt.tsx:221 +msgid "Destination party" +msgstr "" + #: src/components/PartyPendingComponent.tsx:99 msgid "Did you know?" msgstr "¿Sabías que?" @@ -671,7 +695,7 @@ msgstr "Las cantidades de los gastos no coinciden con el total. Por favor, revis msgid "Expense updated" msgstr "Gasto actualizado" -#: src/routes/party.$partyId.tsx:357 +#: src/routes/party.$partyId.tsx:358 msgid "Expenses" msgstr "Gastos" @@ -707,6 +731,10 @@ msgstr "Error al cargar las licencias. Por favor, inténtalo más tarde." msgid "Failed to mark expense as paid" msgstr "Error al marcar el gasto como pagado" +#: src/routes/party_.$partyId.transfer-debt.tsx:188 +msgid "Failed to transfer debt" +msgstr "" + #: src/routes/party_.$partyId.expense.$expenseId_.edit.tsx:171 msgid "Failed to update expense" msgstr "Error al actualizar el gasto" @@ -779,6 +807,10 @@ msgstr "Volver" msgid "Go Back" msgstr "Volver" +#: src/routes/party_.$partyId.transfer-debt.tsx:121 +msgid "Go back and try again from the balances list." +msgstr "" + #: src/routes/migrate_.tricount.tsx:315 msgid "Go back to home" msgstr "Volver al inicio" @@ -811,7 +843,7 @@ msgstr "¿Tienes una idea para mejorar trizum? Nos encantaría escucharla." msgid "Heads up!" msgstr "¡Atención!" -#: src/routes/party.$partyId.tsx:772 +#: src/routes/party.$partyId.tsx:775 msgid "Here is a list of operations you and other party members can do to balance your position." msgstr "Aquí tienes una lista de operaciones que tú y otros miembros del grupo podéis hacer para equilibrar vuestra posición." @@ -840,7 +872,7 @@ msgstr "¿Cómo quieres llamar a este grupo?" msgid "How does offline sync work?" msgstr "¿Cómo funciona la sincronización sin conexión?" -#: src/routes/party.$partyId.tsx:768 +#: src/routes/party.$partyId.tsx:771 msgid "How should I balance?" msgstr "¿Cómo debería equilibrar?" @@ -868,7 +900,7 @@ msgstr "La imagen debe ser menor de 10MB" msgid "Impact on my balance: <0/>" msgstr "Impacto en mi balance: <0/>" -#: src/routes/party.$partyId.tsx:660 +#: src/routes/party.$partyId.tsx:661 msgid "Impact on my balance: <0/>" msgstr "Impacto en mi balance: <0/>" @@ -1011,7 +1043,7 @@ msgstr "Último uso" msgid "Last year" msgstr "Año pasado" -#: src/routes/party.$partyId.tsx:341 +#: src/routes/party.$partyId.tsx:342 msgid "Leave party" msgstr "Dejar el grupo" @@ -1085,7 +1117,7 @@ msgstr "Gestiona la lista de participantes. Los miembros existentes solo pueden #: src/routes/party_.$partyId.pay.tsx:93 #: src/routes/party_.$partyId.pay.tsx:127 -#: src/routes/party.$partyId.tsx:1010 +#: src/routes/party.$partyId.tsx:1020 msgid "Mark as paid" msgstr "Marcar como pagado" @@ -1111,7 +1143,7 @@ msgstr "Yo" #: src/components/ExpenseEditor.tsx:268 #: src/routes/index.tsx:117 #: src/routes/party_.$partyId.expense.$expenseId.tsx:103 -#: src/routes/party.$partyId.tsx:225 +#: src/routes/party.$partyId.tsx:226 msgid "Menu" msgstr "Menú" @@ -1138,6 +1170,7 @@ msgid "Migration successful" msgstr "Migración exitosa" #: src/routes/party_.$partyId.pay.tsx:24 +#: src/routes/party_.$partyId.transfer-debt.tsx:39 msgid "Missing search params" msgstr "Faltan parámetros de búsqueda" @@ -1179,7 +1212,7 @@ msgstr "Kyat Birmano" #: src/routes/new.tsx:186 #: src/routes/party_.$partyId.settings.tsx:166 -#: src/routes/party.$partyId.tsx:175 +#: src/routes/party.$partyId.tsx:176 msgid "Name" msgstr "Nombre" @@ -1252,6 +1285,10 @@ msgstr "No se encontró cámara en este dispositivo" msgid "No currency" msgstr "Sin moneda" +#: src/routes/party_.$partyId.transfer-debt.tsx:215 +msgid "No destination party available" +msgstr "" + #: src/components/EmojiPicker.tsx:333 msgid "No emoji found" msgstr "No se encontraron emojis" @@ -1269,7 +1306,11 @@ msgstr "No hay estadísticas de gastos para este período" msgid "No spending stats yet" msgstr "Aún no hay estadísticas de gastos" -#: src/routes/party.$partyId.tsx:854 +#: src/routes/party_.$partyId.transfer-debt.tsx:291 +msgid "Nobody else is available in this party" +msgstr "" + +#: src/routes/party.$partyId.tsx:858 msgid "Nobody owes you money!" msgstr "¡Nadie te debe dinero!" @@ -1309,10 +1350,14 @@ msgstr "Los grupos antiguos quedan apartados, no desaparecen" msgid "Omani Rial" msgstr "Rial Omaní" -#: src/routes/party.$partyId.tsx:266 +#: src/routes/party.$partyId.tsx:267 msgid "Only show your expenses" msgstr "Mostrar solo tus gastos" +#: src/routes/party_.$partyId.transfer-debt.tsx:131 +msgid "Only your own debt can be transferred" +msgstr "" + #: src/routes/index.tsx:377 msgid "Open archived parties" msgstr "Abrir grupos archivados" @@ -1341,12 +1386,13 @@ msgstr "Abrir paréntesis" msgid "or enter code" msgstr "o introduce código" -#: src/routes/party.$partyId.tsx:870 +#: src/routes/party.$partyId.tsx:874 msgid "Other operations" msgstr "Otras operaciones" #: src/routes/party_.$partyId.pay.tsx:104 -#: src/routes/party.$partyId.tsx:976 +#: src/routes/party_.$partyId.transfer-debt.tsx:380 +#: src/routes/party.$partyId.tsx:986 msgid "owes" msgstr "le debe" @@ -1464,15 +1510,15 @@ msgid "Paste the Tricount sharing message, URL (e.g., https://tricount.com/abc12 msgstr "Pega el mensaje de compartir de Tricount, la URL (p. ej., https://tricount.com/abc123), o la clave directa" #: src/routes/party_.$partyId.pay.tsx:93 -#: src/routes/party.$partyId.tsx:1010 +#: src/routes/party.$partyId.tsx:1020 msgid "Pay" msgstr "Pagar" -#: src/routes/party.$partyId.tsx:831 +#: src/routes/party.$partyId.tsx:835 msgid "People that owe you money" msgstr "Personas que te deben dinero" -#: src/routes/party.$partyId.tsx:262 +#: src/routes/party.$partyId.tsx:263 msgid "Personal mode" msgstr "Modo personal" @@ -1572,6 +1618,10 @@ msgstr "Clasificación según cuánto ha pagado cada participante en el período msgid "Real-Time Sync" msgstr "Sincronización en Tiempo Real" +#: src/routes/party_.$partyId.transfer-debt.tsx:268 +msgid "Recommendations" +msgstr "" + #: src/components/UpdateController.tsx:28 msgid "Reload" msgstr "Recargar" @@ -1663,7 +1713,7 @@ msgstr "Buscar emoji" msgid "Search emoji..." msgstr "Buscar emoji..." -#: src/routes/party.$partyId.tsx:294 +#: src/routes/party.$partyId.tsx:295 msgid "See spending totals and rankings" msgstr "Ver totales y clasificación de gasto" @@ -1684,7 +1734,7 @@ msgid "Set up your username, avatar, and preferences" msgstr "Configura tu nombre de usuario, avatar y preferencias" #: src/routes/index.tsx:133 -#: src/routes/party.$partyId.tsx:329 +#: src/routes/party.$partyId.tsx:330 #: src/routes/settings.tsx:91 msgid "Settings" msgstr "Configuración" @@ -1699,7 +1749,7 @@ msgstr "Rupia de Seychelles" #: src/routes/party_.$partyId.share.tsx:58 #: src/routes/party_.$partyId.share.tsx:93 -#: src/routes/party.$partyId.tsx:312 +#: src/routes/party.$partyId.tsx:313 msgid "Share party" msgstr "Compartir grupo" @@ -1751,7 +1801,7 @@ msgstr "Chelín Somalí" msgid "Something went wrong" msgstr "Algo salió mal" -#: src/routes/party.$partyId.tsx:162 +#: src/routes/party.$partyId.tsx:163 msgid "Sort balances" msgstr "Ordenar balances" @@ -1796,7 +1846,7 @@ msgstr "Fecha de inicio" msgid "Start tracking expenses with your group" msgstr "Comienza a registrar gastos con tu grupo" -#: src/routes/party.$partyId.tsx:290 +#: src/routes/party.$partyId.tsx:291 msgid "Stats" msgstr "Estadísticas" @@ -1896,7 +1946,7 @@ msgstr "Hacer foto" msgid "Tanzanian Shilling" msgstr "Chelín Tanzano" -#: src/routes/party.$partyId.tsx:248 +#: src/routes/party.$partyId.tsx:249 msgid "Tap to change" msgstr "Toca para cambiar" @@ -1934,6 +1984,10 @@ msgstr "Licencias de Terceros" msgid "This application uses the following open source libraries and tools. Below are their licenses and attributions." msgstr "Esta aplicación utiliza las siguientes bibliotecas y herramientas de código abierto. A continuación se muestran sus licencias y atribuciones." +#: src/routes/party_.$partyId.transfer-debt.tsx:120 +msgid "This debt is no longer available" +msgstr "" + #: src/hooks/useMediaFileActions.ts:34 msgid "This HEIC or HEIF image could not be processed. Try another photo or export it as JPEG or PNG." msgstr "Esta imagen HEIC o HEIF no se pudo procesar. Prueba con otra foto o expórtala como JPEG o PNG." @@ -1942,10 +1996,22 @@ msgstr "Esta imagen HEIC o HEIF no se pudo procesar. Prueba con otra foto o exp msgid "This isn't a valid ID" msgstr "Este no es un ID válido" +#: src/routes/party_.$partyId.transfer-debt.tsx:292 +msgid "This party needs another active participant besides you to receive the transferred debt." +msgstr "" + #: src/routes/about.tsx:144 msgid "This project uses various open source libraries and tools." msgstr "Este proyecto utiliza varias bibliotecas y herramientas de código abierto." +#: src/routes/party_.$partyId.transfer-debt.tsx:296 +msgid "This will settle the debt in <0>{0} and create the same debt in <1>{1}, where <2>{2} is owed by <3>{3}." +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:306 +msgid "This will settle the debt in <0>{originPartyName} and create the same debt in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} is owed by <3>{destinationCurrentParticipantName}." +msgstr "" + #. Label for the timeframe filter on the party stats screen #: src/components/PartyStatsView.tsx:270 msgid "Timeframe" @@ -1987,6 +2053,18 @@ msgstr "Total pagado por cada participante en el período seleccionado." msgid "Total spent" msgstr "Total gastado" +#: src/routes/party_.$partyId.transfer-debt.tsx:118 +#: src/routes/party_.$partyId.transfer-debt.tsx:129 +#: src/routes/party_.$partyId.transfer-debt.tsx:204 +#: src/routes/party_.$partyId.transfer-debt.tsx:333 +#: src/routes/party.$partyId.tsx:1041 +msgid "Transfer debt" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:186 +msgid "Transferring debt..." +msgstr "" + #: src/routes/migrate_.tricount.tsx:57 msgid "Tricount key or URL is required" msgstr "Se requiere la clave o URL de Tricount" @@ -2151,7 +2229,7 @@ msgstr "Ver Licencias de Terceros" msgid "Viewing as {0}" msgstr "Viendo como {0}" -#: src/routes/party.$partyId.tsx:244 +#: src/routes/party.$partyId.tsx:245 msgid "Viewing as {participantName}" msgstr "Viendo como {participantName}" @@ -2172,6 +2250,10 @@ msgstr "Bienvenid@ a trizum" msgid "What is this party about?" msgstr "¿De qué trata este grupo?" +#: src/routes/party_.$partyId.transfer-debt.tsx:301 +msgid "What will happen" +msgstr "" + #: src/routes/settings.tsx:150 msgid "What's your preferred way to be addressed?" msgstr "¿Cuál es tu forma preferida de que te llamen?" @@ -2180,6 +2262,14 @@ msgstr "¿Cuál es tu forma preferida de que te llamen?" msgid "Who are you?" msgstr "¿Quién eres?" +#: src/routes/party_.$partyId.transfer-debt.tsx:239 +msgid "Who is {0} in this party?" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:249 +msgid "Who is {sourceCreditorName} in this party?" +msgstr "" + #: src/routes/new.tsx:279 msgid "Who is invited to this party? You can add more participants later." msgstr "¿Quién está invitado a este grupo? Puedes añadir más participantes más tarde." @@ -2208,19 +2298,31 @@ msgstr "Rial Yemení" msgid "Yes! Your data is stored locally on your device and only synced with people you explicitly share your groups with. We don't have access to your expense data." msgstr "¡Sí! Tus datos se almacenan localmente en tu dispositivo y solo se sincronizan con las personas con las que compartes explícitamente tus grupos. No tenemos acceso a tus datos de gastos." +#: src/routes/party_.$partyId.transfer-debt.tsx:239 +msgid "You are" +msgstr "" + #: src/components/PartyPendingComponent.tsx:16 msgid "You can add photos to your expenses for better tracking." msgstr "Puedes añadir fotos a tus gastos para un mejor seguimiento." -#: src/routes/party.$partyId.tsx:127 +#: src/routes/party_.$partyId.transfer-debt.tsx:132 +msgid "You can only transfer debt from actions where you are the one who owes the money." +msgstr "" + +#: src/routes/party.$partyId.tsx:128 msgid "You left the party!" msgstr "¡Has dejado el grupo!" -#: src/routes/party.$partyId.tsx:792 +#: src/routes/party_.$partyId.transfer-debt.tsx:216 +msgid "You need another active party with the same currency to transfer this debt." +msgstr "" + +#: src/routes/party.$partyId.tsx:795 msgid "You owe money to people" msgstr "Debes dinero a personas" -#: src/routes/party.$partyId.tsx:815 +#: src/routes/party.$partyId.tsx:819 msgid "You're debt free!" msgstr "¡Estás libre de deudas!" diff --git a/packages/pwa/src/hooks/useEligibleDebtTransferParties.ts b/packages/pwa/src/hooks/useEligibleDebtTransferParties.ts new file mode 100644 index 00000000..6cd94a42 --- /dev/null +++ b/packages/pwa/src/hooks/useEligibleDebtTransferParties.ts @@ -0,0 +1,35 @@ +import type { Party, PartyParticipant } from "#src/models/party.ts"; +import { getOrderedPartySections } from "#src/lib/partyListOrdering.ts"; +import { useMultipleSuspenseDocument } from "#src/lib/automerge/suspense-hooks.ts"; +import { useCurrentParty } from "./useParty"; +import { usePartyList } from "./usePartyList"; + +export interface EligibleDebtTransferParty { + party: Party; + currentParticipantId: PartyParticipant["id"]; +} + +export function useEligibleDebtTransferParties(): EligibleDebtTransferParty[] { + const { party: originParty } = useCurrentParty(); + const { partyList } = usePartyList(); + const { activePartyIds } = getOrderedPartySections(partyList); + + const joinedActivePartyIds = activePartyIds.filter((partyId) => { + return ( + partyId !== originParty.id && + partyList.participantInParties[partyId] !== undefined + ); + }); + + const partyEntries = useMultipleSuspenseDocument(joinedActivePartyIds); + + return partyEntries + .map(({ doc }) => doc) + .filter((party): party is Party => { + return !!party && party.currency === originParty.currency; + }) + .map((party) => ({ + party, + currentParticipantId: partyList.participantInParties[party.id], + })); +} diff --git a/packages/pwa/src/hooks/useParty.ts b/packages/pwa/src/hooks/useParty.ts index cec606f4..d1271dd6 100644 --- a/packages/pwa/src/hooks/useParty.ts +++ b/packages/pwa/src/hooks/useParty.ts @@ -30,6 +30,7 @@ import { import { clone } from "@opentf/std"; import { useParams } from "@tanstack/react-router"; import { getLogger } from "#src/lib/log.ts"; +import { createDebtTransferExpenses } from "#src/lib/debtTransfer.ts"; const logger = getLogger("hooks", "useParty"); @@ -395,6 +396,103 @@ export function getPartyHelpers(repo: Repo, handle: DocHandle) { return true; } + async function transferDebtToParty({ + destinationPartyId, + originDebtorId, + originCreditorId, + destinationDebtorId, + destinationCreditorId, + amount, + paidAt, + originExpenseName, + destinationExpenseName, + }: { + destinationPartyId: Party["id"]; + originDebtorId: PartyParticipant["id"]; + originCreditorId: PartyParticipant["id"]; + destinationDebtorId: PartyParticipant["id"]; + destinationCreditorId: PartyParticipant["id"]; + amount: number; + paidAt: Date; + originExpenseName: string; + destinationExpenseName: string; + }) { + const originParty = handle.doc(); + + if (!originParty) { + throw new Error("Party not found, this should not happen"); + } + + if (destinationPartyId === originParty.id) { + throw new Error("Cannot transfer debt to the same party"); + } + + if (!originParty.participants[originDebtorId]) { + throw new Error("Origin debtor not found"); + } + + if (!originParty.participants[originCreditorId]) { + throw new Error("Origin creditor not found"); + } + + const destinationHandle = await repo.find(destinationPartyId); + const destinationParty = destinationHandle.doc(); + + if (!destinationParty) { + throw new Error("Destination party not found"); + } + + if (destinationParty.currency !== originParty.currency) { + throw new Error( + "Cannot transfer debt between parties with different currencies", + ); + } + + if (!destinationParty.participants[destinationDebtorId]) { + throw new Error("Destination debtor not found"); + } + + if (!destinationParty.participants[destinationCreditorId]) { + throw new Error("Destination creditor not found"); + } + + const destinationHelpers = getPartyHelpers(repo, destinationHandle); + const { originExpense, destinationExpense } = createDebtTransferExpenses({ + amount, + originDebtorId, + originCreditorId, + destinationDebtorId, + destinationCreditorId, + paidAt, + originExpenseName, + destinationExpenseName, + }); + + const createdDestinationExpense = + await destinationHelpers.addExpenseToParty(destinationExpense); + + try { + const createdOriginExpense = await addExpenseToParty(originExpense); + + return { + originExpense: createdOriginExpense, + destinationExpense: createdDestinationExpense, + }; + } catch (error) { + try { + await destinationHelpers.removeExpense(createdDestinationExpense.id); + } catch (rollbackError) { + logger.error("Failed to rollback destination debt transfer expense", { + rollbackError, + destinationExpenseId: createdDestinationExpense.id, + destinationPartyId, + }); + } + + throw error; + } + } + async function recalculateBalances() { const party = handle.doc(); @@ -442,6 +540,7 @@ export function getPartyHelpers(repo: Repo, handle: DocHandle) { updateSettings, setParticipantDetails, addExpenseToParty, + transferDebtToParty, updateExpense, removeExpense, recalculateBalances, diff --git a/packages/pwa/src/lib/debtTransfer.test.ts b/packages/pwa/src/lib/debtTransfer.test.ts new file mode 100644 index 00000000..625a4ec8 --- /dev/null +++ b/packages/pwa/src/lib/debtTransfer.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "vitest"; +import { + createDebtTransferExpenses, + getDebtTransferParticipantMatch, +} from "./debtTransfer"; + +describe("createDebtTransferExpenses", () => { + test("creates a settlement expense in the origin party and a new debt in the destination party", () => { + const paidAt = new Date("2026-04-18T10:20:30.000Z"); + + const { originExpense, destinationExpense } = createDebtTransferExpenses({ + amount: 4_000, + originDebtorId: "me-origin", + originCreditorId: "juan-origin", + destinationDebtorId: "me-destination", + destinationCreditorId: "juan-destination", + paidAt, + originExpenseName: "Debt transfer to another party", + destinationExpenseName: "Debt transfer from another party", + }); + + expect(originExpense).toEqual({ + name: "Debt transfer to another party", + paidAt, + paidBy: { + "me-origin": 4_000, + }, + shares: { + "juan-origin": { + type: "divide", + value: 1, + }, + }, + photos: [], + isTransfer: true, + }); + + expect(destinationExpense).toEqual({ + name: "Debt transfer from another party", + paidAt, + paidBy: { + "juan-destination": 4_000, + }, + shares: { + "me-destination": { + type: "divide", + value: 1, + }, + }, + photos: [], + isTransfer: true, + }); + }); + + test("rejects non-positive transfer amounts", () => { + expect(() => + createDebtTransferExpenses({ + amount: 0, + originDebtorId: "me-origin", + originCreditorId: "juan-origin", + destinationDebtorId: "me-destination", + destinationCreditorId: "juan-destination", + paidAt: new Date("2026-04-18T10:20:30.000Z"), + originExpenseName: "Debt transfer to another party", + destinationExpenseName: "Debt transfer from another party", + }), + ).toThrow("Debt transfer amount must be greater than 0"); + }); +}); + +describe("getDebtTransferParticipantMatch", () => { + test("preselects a single full-name match", () => { + expect( + getDebtTransferParticipantMatch({ + sourceName: "Juan Smith", + participants: [ + { id: "juan-1", name: "Juan Smith" }, + { id: "juan-2", name: "Juan S." }, + ], + }), + ).toEqual({ + exactMatchParticipantId: "juan-1", + recommendedParticipantIds: [], + }); + }); + + test("returns quick recommendations when the name only partially matches", () => { + expect( + getDebtTransferParticipantMatch({ + sourceName: "Juan", + participants: [ + { id: "juan-smith", name: "Juan Smith" }, + { id: "dani", name: "Dani" }, + { id: "juan-perez", name: "Juan Perez" }, + ], + }), + ).toEqual({ + exactMatchParticipantId: null, + recommendedParticipantIds: ["juan-perez", "juan-smith"], + }); + }); + + test("does not auto-pick when multiple full-name matches exist", () => { + expect( + getDebtTransferParticipantMatch({ + sourceName: "Juan", + participants: [ + { id: "juan-1", name: "Juan" }, + { id: "juan-2", name: "Juan" }, + { id: "dani", name: "Dani" }, + ], + }), + ).toEqual({ + exactMatchParticipantId: null, + recommendedParticipantIds: ["juan-1", "juan-2"], + }); + }); + + test("normalizes accents, punctuation, and spacing when matching", () => { + expect( + getDebtTransferParticipantMatch({ + sourceName: " Juán-Smith ", + participants: [{ id: "juan-smith", name: "Juan Smith" }], + }), + ).toEqual({ + exactMatchParticipantId: "juan-smith", + recommendedParticipantIds: [], + }); + }); +}); diff --git a/packages/pwa/src/lib/debtTransfer.ts b/packages/pwa/src/lib/debtTransfer.ts new file mode 100644 index 00000000..bab9efd4 --- /dev/null +++ b/packages/pwa/src/lib/debtTransfer.ts @@ -0,0 +1,176 @@ +import type { Expense } from "#src/models/expense.ts"; +import type { PartyParticipant } from "#src/models/party.ts"; + +interface CreateDebtTransferExpensesOptions { + amount: number; + originDebtorId: string; + originCreditorId: string; + destinationDebtorId: string; + destinationCreditorId: string; + paidAt: Date; + originExpenseName: string; + destinationExpenseName: string; +} + +interface GetDebtTransferParticipantMatchOptions { + sourceName: string; + participants: Array>; + recommendationLimit?: number; +} + +export interface DebtTransferParticipantMatch { + exactMatchParticipantId: PartyParticipant["id"] | null; + recommendedParticipantIds: PartyParticipant["id"][]; +} + +export function createDebtTransferExpenses({ + amount, + originDebtorId, + originCreditorId, + destinationDebtorId, + destinationCreditorId, + paidAt, + originExpenseName, + destinationExpenseName, +}: CreateDebtTransferExpensesOptions): { + originExpense: Omit; + destinationExpense: Omit; +} { + if (amount <= 0) { + throw new Error("Debt transfer amount must be greater than 0"); + } + + return { + originExpense: { + name: originExpenseName, + paidAt: new Date(paidAt), + paidBy: { + [originDebtorId]: amount, + }, + shares: { + [originCreditorId]: { + type: "divide", + value: 1, + }, + }, + photos: [], + isTransfer: true, + }, + destinationExpense: { + name: destinationExpenseName, + paidAt: new Date(paidAt), + paidBy: { + [destinationCreditorId]: amount, + }, + shares: { + [destinationDebtorId]: { + type: "divide", + value: 1, + }, + }, + photos: [], + isTransfer: true, + }, + }; +} + +export function getDebtTransferParticipantMatch({ + sourceName, + participants, + recommendationLimit = 3, +}: GetDebtTransferParticipantMatchOptions): DebtTransferParticipantMatch { + const normalizedSourceName = normalizeParticipantName(sourceName); + + if (!normalizedSourceName) { + return { + exactMatchParticipantId: null, + recommendedParticipantIds: [], + }; + } + + const normalizedSourceTokens = tokenizeParticipantName(normalizedSourceName); + const participantsWithMatchScore = participants.map((participant) => { + const normalizedParticipantName = normalizeParticipantName( + participant.name, + ); + + return { + participant, + normalizedParticipantName, + matchScore: getParticipantMatchScore({ + normalizedSourceName, + normalizedSourceTokens, + normalizedParticipantName, + }), + }; + }); + + const exactMatches = participantsWithMatchScore.filter( + ({ normalizedParticipantName }) => + normalizedParticipantName === normalizedSourceName, + ); + + if (exactMatches.length === 1) { + return { + exactMatchParticipantId: exactMatches[0].participant.id, + recommendedParticipantIds: [], + }; + } + + return { + exactMatchParticipantId: null, + recommendedParticipantIds: participantsWithMatchScore + .filter(({ matchScore }) => matchScore > 0) + .sort((left, right) => { + if (left.matchScore !== right.matchScore) { + return right.matchScore - left.matchScore; + } + + return left.participant.name.localeCompare(right.participant.name); + }) + .slice(0, recommendationLimit) + .map(({ participant }) => participant.id), + }; +} + +function getParticipantMatchScore({ + normalizedSourceName, + normalizedSourceTokens, + normalizedParticipantName, +}: { + normalizedSourceName: string; + normalizedSourceTokens: string[]; + normalizedParticipantName: string; +}) { + if (!normalizedParticipantName) { + return 0; + } + + if (normalizedParticipantName === normalizedSourceName) { + return 100; + } + + const participantTokens = tokenizeParticipantName(normalizedParticipantName); + const sharedTokens = normalizedSourceTokens.filter((token) => + participantTokens.includes(token), + ).length; + const hasContainedName = + normalizedParticipantName.includes(normalizedSourceName) || + normalizedSourceName.includes(normalizedParticipantName); + + return sharedTokens * 10 + (hasContainedName ? 5 : 0); +} + +function tokenizeParticipantName(name: string) { + return name.split(" ").filter(Boolean); +} + +function normalizeParticipantName(name: string) { + return name + .trim() + .normalize("NFD") + .replace(/\p{Diacritic}/gu, "") + .toLowerCase() + .replace(/['’.-]+/g, " ") + .replace(/\s+/g, " "); +} diff --git a/packages/pwa/src/routeTree.gen.ts b/packages/pwa/src/routeTree.gen.ts index be0c82dd..2a906924 100644 --- a/packages/pwa/src/routeTree.gen.ts +++ b/packages/pwa/src/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as PartyPartyIdRouteImport } from './routes/party.$partyId' import { Route as MigrateTricountRouteImport } from './routes/migrate_.tricount' import { Route as AboutThirdPartyLicensesRouteImport } from './routes/about_.third-party-licenses' import { Route as PartyPartyIdWhoRouteImport } from './routes/party_.$partyId.who' +import { Route as PartyPartyIdTransferDebtRouteImport } from './routes/party_.$partyId.transfer-debt' import { Route as PartyPartyIdStatsRouteImport } from './routes/party_.$partyId.stats' import { Route as PartyPartyIdShareRouteImport } from './routes/party_.$partyId.share' import { Route as PartyPartyIdSettingsRouteImport } from './routes/party_.$partyId.settings' @@ -89,6 +90,12 @@ const PartyPartyIdWhoRoute = PartyPartyIdWhoRouteImport.update({ path: '/party/$partyId/who', getParentRoute: () => rootRouteImport, } as any) +const PartyPartyIdTransferDebtRoute = + PartyPartyIdTransferDebtRouteImport.update({ + id: '/party_/$partyId/transfer-debt', + path: '/party/$partyId/transfer-debt', + getParentRoute: () => rootRouteImport, + } as any) const PartyPartyIdStatsRoute = PartyPartyIdStatsRouteImport.update({ id: '/party_/$partyId/stats', path: '/party/$partyId/stats', @@ -144,6 +151,7 @@ export interface FileRoutesByFullPath { '/party/$partyId/settings': typeof PartyPartyIdSettingsRoute '/party/$partyId/share': typeof PartyPartyIdShareRoute '/party/$partyId/stats': typeof PartyPartyIdStatsRoute + '/party/$partyId/transfer-debt': typeof PartyPartyIdTransferDebtRoute '/party/$partyId/who': typeof PartyPartyIdWhoRoute '/party/$partyId/expense/$expenseId': typeof PartyPartyIdExpenseExpenseIdRoute '/party/$partyId/expense/$expenseId/edit': typeof PartyPartyIdExpenseExpenseIdEditRoute @@ -165,6 +173,7 @@ export interface FileRoutesByTo { '/party/$partyId/settings': typeof PartyPartyIdSettingsRoute '/party/$partyId/share': typeof PartyPartyIdShareRoute '/party/$partyId/stats': typeof PartyPartyIdStatsRoute + '/party/$partyId/transfer-debt': typeof PartyPartyIdTransferDebtRoute '/party/$partyId/who': typeof PartyPartyIdWhoRoute '/party/$partyId/expense/$expenseId': typeof PartyPartyIdExpenseExpenseIdRoute '/party/$partyId/expense/$expenseId/edit': typeof PartyPartyIdExpenseExpenseIdEditRoute @@ -187,6 +196,7 @@ export interface FileRoutesById { '/party_/$partyId/settings': typeof PartyPartyIdSettingsRoute '/party_/$partyId/share': typeof PartyPartyIdShareRoute '/party_/$partyId/stats': typeof PartyPartyIdStatsRoute + '/party_/$partyId/transfer-debt': typeof PartyPartyIdTransferDebtRoute '/party_/$partyId/who': typeof PartyPartyIdWhoRoute '/party_/$partyId/expense/$expenseId': typeof PartyPartyIdExpenseExpenseIdRoute '/party_/$partyId/expense/$expenseId_/edit': typeof PartyPartyIdExpenseExpenseIdEditRoute @@ -210,6 +220,7 @@ export interface FileRouteTypes { | '/party/$partyId/settings' | '/party/$partyId/share' | '/party/$partyId/stats' + | '/party/$partyId/transfer-debt' | '/party/$partyId/who' | '/party/$partyId/expense/$expenseId' | '/party/$partyId/expense/$expenseId/edit' @@ -231,6 +242,7 @@ export interface FileRouteTypes { | '/party/$partyId/settings' | '/party/$partyId/share' | '/party/$partyId/stats' + | '/party/$partyId/transfer-debt' | '/party/$partyId/who' | '/party/$partyId/expense/$expenseId' | '/party/$partyId/expense/$expenseId/edit' @@ -252,6 +264,7 @@ export interface FileRouteTypes { | '/party_/$partyId/settings' | '/party_/$partyId/share' | '/party_/$partyId/stats' + | '/party_/$partyId/transfer-debt' | '/party_/$partyId/who' | '/party_/$partyId/expense/$expenseId' | '/party_/$partyId/expense/$expenseId_/edit' @@ -274,6 +287,7 @@ export interface RootRouteChildren { PartyPartyIdSettingsRoute: typeof PartyPartyIdSettingsRoute PartyPartyIdShareRoute: typeof PartyPartyIdShareRoute PartyPartyIdStatsRoute: typeof PartyPartyIdStatsRoute + PartyPartyIdTransferDebtRoute: typeof PartyPartyIdTransferDebtRoute PartyPartyIdWhoRoute: typeof PartyPartyIdWhoRoute PartyPartyIdExpenseExpenseIdRoute: typeof PartyPartyIdExpenseExpenseIdRoute PartyPartyIdExpenseExpenseIdEditRoute: typeof PartyPartyIdExpenseExpenseIdEditRoute @@ -365,6 +379,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PartyPartyIdWhoRouteImport parentRoute: typeof rootRouteImport } + '/party_/$partyId/transfer-debt': { + id: '/party_/$partyId/transfer-debt' + path: '/party/$partyId/transfer-debt' + fullPath: '/party/$partyId/transfer-debt' + preLoaderRoute: typeof PartyPartyIdTransferDebtRouteImport + parentRoute: typeof rootRouteImport + } '/party_/$partyId/stats': { id: '/party_/$partyId/stats' path: '/party/$partyId/stats' @@ -434,6 +455,7 @@ const rootRouteChildren: RootRouteChildren = { PartyPartyIdSettingsRoute: PartyPartyIdSettingsRoute, PartyPartyIdShareRoute: PartyPartyIdShareRoute, PartyPartyIdStatsRoute: PartyPartyIdStatsRoute, + PartyPartyIdTransferDebtRoute: PartyPartyIdTransferDebtRoute, PartyPartyIdWhoRoute: PartyPartyIdWhoRoute, PartyPartyIdExpenseExpenseIdRoute: PartyPartyIdExpenseExpenseIdRoute, PartyPartyIdExpenseExpenseIdEditRoute: PartyPartyIdExpenseExpenseIdEditRoute, diff --git a/packages/pwa/src/routes/party.$partyId.tsx b/packages/pwa/src/routes/party.$partyId.tsx index 4d0b72be..9528e511 100644 --- a/packages/pwa/src/routes/party.$partyId.tsx +++ b/packages/pwa/src/routes/party.$partyId.tsx @@ -20,6 +20,7 @@ import { toast } from "sonner"; import { usePartyPaginatedExpenses } from "#src/hooks/usePartyPaginatedExpenses.js"; import { useCurrentParty, useParty } from "#src/hooks/useParty.js"; import { useCurrentParticipant } from "#src/hooks/useCurrentParticipant.js"; +import { useEligibleDebtTransferParties } from "#src/hooks/useEligibleDebtTransferParties.ts"; import { CurrencyText } from "#src/components/CurrencyText.js"; import { guardParticipatingInParty } from "#src/lib/guards.js"; import { AnimatedTabs } from "#src/ui/AnimatedTabs.js"; @@ -728,6 +729,8 @@ function Balances({ const isFullyBalanced = userOwesMap.length === 0 && owedToUserMap.length === 0; + const eligibleTransferParties = useEligibleDebtTransferParties(); + const canTransferDebt = eligibleTransferParties.length > 0; // Show other transactions not involving the current user const allOtherDiffs = simplifiedTransactions @@ -799,6 +802,7 @@ function Balances({ fromId={participant.id} toId={participantId} amount={amount} + canTransferDebt={canTransferDebt} /> ))} @@ -955,9 +959,15 @@ interface BalanceActionItemProps { fromId: PartyParticipant["id"]; toId: PartyParticipant["id"]; amount: number; + canTransferDebt?: boolean; } -function BalanceActionItem({ fromId, toId, amount }: BalanceActionItemProps) { +function BalanceActionItem({ + fromId, + toId, + amount, + canTransferDebt = false, +}: BalanceActionItemProps) { const { party } = useCurrentParty(); const me = useCurrentParticipant(); const from = party.participants[fromId]; @@ -989,7 +999,7 @@ function BalanceActionItem({ fromId, toId, amount }: BalanceActionItemProps) { -
+
+ + {isFromMe && canTransferDebt ? ( + + ) : null}
); diff --git a/packages/pwa/src/routes/party_.$partyId.transfer-debt.tsx b/packages/pwa/src/routes/party_.$partyId.transfer-debt.tsx new file mode 100644 index 00000000..a4610cc1 --- /dev/null +++ b/packages/pwa/src/routes/party_.$partyId.transfer-debt.tsx @@ -0,0 +1,418 @@ +import { Trans } from "@lingui/react/macro"; +import { t } from "@lingui/core/macro"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import type { Currency } from "dinero.js"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { BackButton } from "#src/components/BackButton.tsx"; +import { CurrencyText } from "#src/components/CurrencyText.tsx"; +import { PartyPendingComponent } from "#src/components/PartyPendingComponent.tsx"; +import { useCurrentParticipant } from "#src/hooks/useCurrentParticipant.ts"; +import { useEligibleDebtTransferParties } from "#src/hooks/useEligibleDebtTransferParties.ts"; +import { useCurrentParty } from "#src/hooks/useParty.ts"; +import { + getDebtTransferParticipantMatch, + type DebtTransferParticipantMatch, +} from "#src/lib/debtTransfer.ts"; +import { guardParticipatingInParty } from "#src/lib/guards.ts"; +import { Alert, AlertDescription, AlertTitle } from "#src/ui/Alert.tsx"; +import { Button } from "#src/ui/Button.tsx"; +import { Icon } from "#src/ui/Icon.tsx"; +import { AppSelect, SelectItem } from "#src/ui/Select.tsx"; + +interface TransferDebtSearchParams { + fromId: string; + toId: string; + amount: number; +} + +export const Route = createFileRoute("/party_/$partyId/transfer-debt")({ + component: RouteComponent, + pendingComponent: PartyPendingComponent, + validateSearch: (search): TransferDebtSearchParams => { + if ( + typeof search.fromId !== "string" || + typeof search.toId !== "string" || + typeof search.amount !== "number" || + search.amount <= 0 + ) { + throw new Error(t`Missing search params`); + } + + return { + fromId: search.fromId, + toId: search.toId, + amount: search.amount, + }; + }, + async loader({ context, params, location }) { + await guardParticipatingInParty(params.partyId, context, location); + }, +}); + +function RouteComponent() { + const { fromId, toId, amount } = Route.useSearch(); + const { party, transferDebtToParty } = useCurrentParty(); + const currentParticipant = useCurrentParticipant(); + const eligibleDestinationParties = useEligibleDebtTransferParties(); + const navigate = useNavigate(); + + const from = party.participants[fromId]; + const to = party.participants[toId]; + const isSupportedTransfer = fromId === currentParticipant.id; + + const [destinationPartyId, setDestinationPartyId] = useState(() => + eligibleDestinationParties.length === 1 + ? eligibleDestinationParties[0].party.id + : "", + ); + const [destinationParticipantId, setDestinationParticipantId] = + useState(""); + + const destinationPartyOptions = useMemo( + () => + eligibleDestinationParties.map((entry) => ({ + id: entry.party.id, + entry, + })), + [eligibleDestinationParties], + ); + + const selectedDestinationParty = destinationPartyOptions.find( + ({ id }) => id === destinationPartyId, + ); + + const destinationParticipants = useMemo(() => { + if (!selectedDestinationParty) { + return []; + } + + return Object.values(selectedDestinationParty.entry.party.participants) + .filter( + (participant) => + !participant.isArchived && + participant.id !== + selectedDestinationParty.entry.currentParticipantId, + ) + .sort((left, right) => left.name.localeCompare(right.name)); + }, [selectedDestinationParty]); + + const participantMatch = useMemo(() => { + return getDebtTransferParticipantMatch({ + sourceName: to?.name ?? "", + participants: destinationParticipants, + }); + }, [destinationParticipants, to?.name]); + + useEffect(() => { + if (!selectedDestinationParty) { + setDestinationParticipantId(""); + return; + } + + setDestinationParticipantId((currentValue) => { + if ( + destinationParticipants.some( + (participant) => participant.id === currentValue, + ) + ) { + return currentValue; + } + + return participantMatch.exactMatchParticipantId ?? ""; + }); + }, [destinationParticipants, participantMatch, selectedDestinationParty]); + + if (!from || !to) { + return ( + + + + ); + } + + if (!isSupportedTransfer) { + return ( + + + + ); + } + + const destinationCurrentParticipant = selectedDestinationParty + ? selectedDestinationParty.entry.party.participants[ + selectedDestinationParty.entry.currentParticipantId + ] + : null; + const sourceCreditorName = to.name; + const originPartyName = party.name; + const destinationPartyName = selectedDestinationParty?.entry.party.name ?? ""; + const destinationCurrentParticipantName = + destinationCurrentParticipant?.name ?? ""; + const recommendedParticipants = participantMatch.recommendedParticipantIds + .map((participantId) => + destinationParticipants.find( + (participant) => participant.id === participantId, + ), + ) + .filter( + (participant): participant is (typeof destinationParticipants)[number] => + !!participant, + ); + const selectedDestinationCounterparty = destinationParticipants.find( + (participant) => participant.id === destinationParticipantId, + ); + const selectedDestinationCounterpartyName = + selectedDestinationCounterparty?.name ?? ""; + const canTransfer = + !!selectedDestinationParty && + !!selectedDestinationCounterparty && + destinationParticipantId !== ""; + + function onTransferDebt() { + if (!selectedDestinationParty || !selectedDestinationCounterparty) { + return; + } + + const transferPromise = transferDebtToParty({ + destinationPartyId: selectedDestinationParty.entry.party.id, + originDebtorId: fromId, + originCreditorId: toId, + destinationDebtorId: selectedDestinationParty.entry.currentParticipantId, + destinationCreditorId: selectedDestinationCounterparty.id, + amount, + paidAt: new Date(), + originExpenseName: t`Debt transfer to another party`, + destinationExpenseName: t`Debt transfer from another party`, + }); + + toast.promise(transferPromise, { + loading: t`Transferring debt...`, + success: t`Debt transferred`, + error: t`Failed to transfer debt`, + }); + + void transferPromise.then(({ originExpense }) => { + return navigate({ + to: "/party/$partyId/expense/$expenseId", + params: { + partyId: party.id, + expenseId: originExpense.id, + }, + replace: true, + }); + }); + } + + return ( + +
+ + + {eligibleDestinationParties.length === 0 ? ( + + ) : ( + <> + + label={t`Destination party`} + description={t`Choose the party where this debt should continue`} + items={destinationPartyOptions} + selectedKey={destinationPartyId || undefined} + onSelectionChange={(value) => { + setDestinationPartyId(String(value ?? "")); + }} + > + {(option) => ( + + {option.entry.party.name} + + )} + + + {selectedDestinationParty ? ( +
+
+ You are +
+
+ {destinationCurrentParticipantName} +
+
+ ) : null} + + {selectedDestinationParty ? ( + + label={t`Who is ${sourceCreditorName} in this party?`} + description={t`Choose the participant who should receive the transferred debt`} + items={destinationParticipants} + selectedKey={destinationParticipantId || undefined} + onSelectionChange={(value) => { + setDestinationParticipantId(String(value ?? "")); + }} + > + {(participant) => ( + + {participant.name} + + )} + + ) : null} + + {selectedDestinationParty && recommendedParticipants.length > 0 ? ( +
+
+ Recommendations +
+ +
+ {recommendedParticipants.map((participant) => ( + + ))} +
+
+ ) : null} + + {selectedDestinationParty && + destinationParticipants.length === 0 ? ( + + ) : null} + + {selectedDestinationParty && selectedDestinationCounterparty ? ( +
+
+ + + What will happen + +
+ +

+ + This will settle the debt in{" "} + {originPartyName} and + create the same debt in{" "} + {destinationPartyName}, + where{" "} + + {selectedDestinationCounterpartyName} + {" "} + is owed by{" "} + + {destinationCurrentParticipantName} + + . + +

+
+ ) : null} + + + + )} +
+ +
+ + ); +} + +function TransferDebtLayout({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+
+ +

{title}

+
+ + {children} +
+ ); +} + +function DebtSummaryCard({ + fromName, + toName, + amount, + currency, +}: { + fromName: string; + toName: string; + amount: number; + currency: Currency; +}) { + return ( +
+
+ {fromName} + + owes + + {toName} +
+ +
+ +
+
+ ); +} + +function InlineAlert({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( +
+ + + {title} + {description} + +
+ ); +} From 38c0c068a12a5ad2c1b226f695f1f321185652d7 Mon Sep 17 00:00:00 2001 From: HorusGoul Date: Sat, 18 Apr 2026 16:31:03 +0000 Subject: [PATCH 2/5] style: apply automatic fixes Signed-off-by: GitHub Actions --- packages/pwa/locale/en/messages.po | 52 +++++++++++++++--------------- packages/pwa/locale/es/messages.po | 52 +++++++++++++++--------------- 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/packages/pwa/locale/en/messages.po b/packages/pwa/locale/en/messages.po index c012c322..34f20e01 100644 --- a/packages/pwa/locale/en/messages.po +++ b/packages/pwa/locale/en/messages.po @@ -427,11 +427,11 @@ msgstr "Chinese Yuan" msgid "Choose the currency for expenses in this party" msgstr "Choose the currency for expenses in this party" -#: src/routes/party_.$partyId.transfer-debt.tsx:250 +#: src/routes/party_.$partyId.transfer-debt.tsx:260 msgid "Choose the participant who should receive the transferred debt" msgstr "Choose the participant who should receive the transferred debt" -#: src/routes/party_.$partyId.transfer-debt.tsx:222 +#: src/routes/party_.$partyId.transfer-debt.tsx:232 msgid "Choose the party where this debt should continue" msgstr "Choose the party where this debt should continue" @@ -580,15 +580,15 @@ msgstr "Debt settled between {0} and {1}!" msgid "Debt settled between {fromName} and {toName}!" msgstr "Debt settled between {fromName} and {toName}!" -#: src/routes/party_.$partyId.transfer-debt.tsx:182 +#: src/routes/party_.$partyId.transfer-debt.tsx:192 msgid "Debt transfer from another party" msgstr "Debt transfer from another party" -#: src/routes/party_.$partyId.transfer-debt.tsx:181 +#: src/routes/party_.$partyId.transfer-debt.tsx:191 msgid "Debt transfer to another party" msgstr "Debt transfer to another party" -#: src/routes/party_.$partyId.transfer-debt.tsx:187 +#: src/routes/party_.$partyId.transfer-debt.tsx:197 msgid "Debt transferred" msgstr "Debt transferred" @@ -609,7 +609,7 @@ msgstr "Description" msgid "Description must be less than 500 characters" msgstr "Description must be less than 500 characters" -#: src/routes/party_.$partyId.transfer-debt.tsx:221 +#: src/routes/party_.$partyId.transfer-debt.tsx:231 msgid "Destination party" msgstr "Destination party" @@ -775,7 +775,7 @@ msgstr "Failed to load licenses. Please try again later." msgid "Failed to mark expense as paid" msgstr "Failed to mark expense as paid" -#: src/routes/party_.$partyId.transfer-debt.tsx:188 +#: src/routes/party_.$partyId.transfer-debt.tsx:198 msgid "Failed to transfer debt" msgstr "Failed to transfer debt" @@ -851,7 +851,7 @@ msgstr "Go back" msgid "Go Back" msgstr "Go Back" -#: src/routes/party_.$partyId.transfer-debt.tsx:121 +#: src/routes/party_.$partyId.transfer-debt.tsx:131 msgid "Go back and try again from the balances list." msgstr "Go back and try again from the balances list." @@ -1337,7 +1337,7 @@ msgstr "No camera found on this device" msgid "No currency" msgstr "No currency" -#: src/routes/party_.$partyId.transfer-debt.tsx:215 +#: src/routes/party_.$partyId.transfer-debt.tsx:225 msgid "No destination party available" msgstr "No destination party available" @@ -1362,7 +1362,7 @@ msgstr "No spending stats yet" msgid "No updates available" msgstr "No updates available" -#: src/routes/party_.$partyId.transfer-debt.tsx:291 +#: src/routes/party_.$partyId.transfer-debt.tsx:301 msgid "Nobody else is available in this party" msgstr "Nobody else is available in this party" @@ -1410,7 +1410,7 @@ msgstr "Omani Rial" msgid "Only show your expenses" msgstr "Only show your expenses" -#: src/routes/party_.$partyId.transfer-debt.tsx:131 +#: src/routes/party_.$partyId.transfer-debt.tsx:141 msgid "Only your own debt can be transferred" msgstr "Only your own debt can be transferred" @@ -1451,7 +1451,7 @@ msgid "Other operations" msgstr "Other operations" #: src/routes/party_.$partyId.pay.tsx:104 -#: src/routes/party_.$partyId.transfer-debt.tsx:380 +#: src/routes/party_.$partyId.transfer-debt.tsx:390 #: src/routes/party.$partyId.tsx:986 msgid "owes" msgstr "owes" @@ -1690,7 +1690,7 @@ msgstr "Real-Time Sync" msgid "Receipt Attachments" msgstr "Receipt Attachments" -#: src/routes/party_.$partyId.transfer-debt.tsx:268 +#: src/routes/party_.$partyId.transfer-debt.tsx:278 msgid "Recommendations" msgstr "Recommendations" @@ -2060,7 +2060,7 @@ msgstr "Third-Party Licenses" msgid "This application uses the following open source libraries and tools. Below are their licenses and attributions." msgstr "This application uses the following open source libraries and tools. Below are their licenses and attributions." -#: src/routes/party_.$partyId.transfer-debt.tsx:120 +#: src/routes/party_.$partyId.transfer-debt.tsx:130 msgid "This debt is no longer available" msgstr "This debt is no longer available" @@ -2072,7 +2072,7 @@ msgstr "This HEIC or HEIF image could not be processed. Try another photo or exp msgid "This isn't a valid ID" msgstr "This isn't a valid ID" -#: src/routes/party_.$partyId.transfer-debt.tsx:292 +#: src/routes/party_.$partyId.transfer-debt.tsx:302 msgid "This party needs another active participant besides you to receive the transferred debt." msgstr "This party needs another active participant besides you to receive the transferred debt." @@ -2084,7 +2084,7 @@ msgstr "This project uses various open source libraries and tools." msgid "This will settle the debt in <0>{0} and create the same debt in <1>{1}, where <2>{2} is owed by <3>{3}." msgstr "This will settle the debt in <0>{0} and create the same debt in <1>{1}, where <2>{2} is owed by <3>{3}." -#: src/routes/party_.$partyId.transfer-debt.tsx:306 +#: src/routes/party_.$partyId.transfer-debt.tsx:316 msgid "This will settle the debt in <0>{originPartyName} and create the same debt in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} is owed by <3>{destinationCurrentParticipantName}." msgstr "This will settle the debt in <0>{originPartyName} and create the same debt in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} is owed by <3>{destinationCurrentParticipantName}." @@ -2133,15 +2133,15 @@ msgstr "Total spent" msgid "Total split:" msgstr "Total split:" -#: src/routes/party_.$partyId.transfer-debt.tsx:118 -#: src/routes/party_.$partyId.transfer-debt.tsx:129 -#: src/routes/party_.$partyId.transfer-debt.tsx:204 -#: src/routes/party_.$partyId.transfer-debt.tsx:333 +#: src/routes/party_.$partyId.transfer-debt.tsx:128 +#: src/routes/party_.$partyId.transfer-debt.tsx:139 +#: src/routes/party_.$partyId.transfer-debt.tsx:214 +#: src/routes/party_.$partyId.transfer-debt.tsx:343 #: src/routes/party.$partyId.tsx:1041 msgid "Transfer debt" msgstr "Transfer debt" -#: src/routes/party_.$partyId.transfer-debt.tsx:186 +#: src/routes/party_.$partyId.transfer-debt.tsx:196 msgid "Transferring debt..." msgstr "Transferring debt..." @@ -2351,7 +2351,7 @@ msgstr "Welcome to trizum" msgid "What is this party about?" msgstr "What is this party about?" -#: src/routes/party_.$partyId.transfer-debt.tsx:301 +#: src/routes/party_.$partyId.transfer-debt.tsx:311 msgid "What will happen" msgstr "What will happen" @@ -2367,7 +2367,7 @@ msgstr "Who are you?" msgid "Who is {0} in this party?" msgstr "Who is {0} in this party?" -#: src/routes/party_.$partyId.transfer-debt.tsx:249 +#: src/routes/party_.$partyId.transfer-debt.tsx:259 msgid "Who is {sourceCreditorName} in this party?" msgstr "Who is {sourceCreditorName} in this party?" @@ -2399,7 +2399,7 @@ msgstr "Yemeni Rial" msgid "Yes! Your data is stored locally on your device and only synced with people you explicitly share your groups with. We don't have access to your expense data." msgstr "Yes! Your data is stored locally on your device and only synced with people you explicitly share your groups with. We don't have access to your expense data." -#: src/routes/party_.$partyId.transfer-debt.tsx:239 +#: src/routes/party_.$partyId.transfer-debt.tsx:249 msgid "You are" msgstr "You are" @@ -2407,7 +2407,7 @@ msgstr "You are" msgid "You can add photos to your expenses for better tracking." msgstr "You can add photos to your expenses for better tracking." -#: src/routes/party_.$partyId.transfer-debt.tsx:132 +#: src/routes/party_.$partyId.transfer-debt.tsx:142 msgid "You can only transfer debt from actions where you are the one who owes the money." msgstr "You can only transfer debt from actions where you are the one who owes the money." @@ -2415,7 +2415,7 @@ msgstr "You can only transfer debt from actions where you are the one who owes t msgid "You left the party!" msgstr "You left the party!" -#: src/routes/party_.$partyId.transfer-debt.tsx:216 +#: src/routes/party_.$partyId.transfer-debt.tsx:226 msgid "You need another active party with the same currency to transfer this debt." msgstr "You need another active party with the same currency to transfer this debt." diff --git a/packages/pwa/locale/es/messages.po b/packages/pwa/locale/es/messages.po index 52af0c54..26040c29 100644 --- a/packages/pwa/locale/es/messages.po +++ b/packages/pwa/locale/es/messages.po @@ -403,11 +403,11 @@ msgstr "Yuan Chino" msgid "Choose the currency for expenses in this party" msgstr "Elige la moneda para los gastos de este grupo" -#: src/routes/party_.$partyId.transfer-debt.tsx:250 +#: src/routes/party_.$partyId.transfer-debt.tsx:260 msgid "Choose the participant who should receive the transferred debt" msgstr "" -#: src/routes/party_.$partyId.transfer-debt.tsx:222 +#: src/routes/party_.$partyId.transfer-debt.tsx:232 msgid "Choose the party where this debt should continue" msgstr "" @@ -552,15 +552,15 @@ msgstr "¡Deuda saldada entre {0} y {1}!" msgid "Debt settled between {fromName} and {toName}!" msgstr "¡Deuda saldada entre {fromName} y {toName}!" -#: src/routes/party_.$partyId.transfer-debt.tsx:182 +#: src/routes/party_.$partyId.transfer-debt.tsx:192 msgid "Debt transfer from another party" msgstr "" -#: src/routes/party_.$partyId.transfer-debt.tsx:181 +#: src/routes/party_.$partyId.transfer-debt.tsx:191 msgid "Debt transfer to another party" msgstr "" -#: src/routes/party_.$partyId.transfer-debt.tsx:187 +#: src/routes/party_.$partyId.transfer-debt.tsx:197 msgid "Debt transferred" msgstr "" @@ -581,7 +581,7 @@ msgstr "Descripción" msgid "Description must be less than 500 characters" msgstr "La descripción debe tener menos de 500 caracteres" -#: src/routes/party_.$partyId.transfer-debt.tsx:221 +#: src/routes/party_.$partyId.transfer-debt.tsx:231 msgid "Destination party" msgstr "" @@ -731,7 +731,7 @@ msgstr "Error al cargar las licencias. Por favor, inténtalo más tarde." msgid "Failed to mark expense as paid" msgstr "Error al marcar el gasto como pagado" -#: src/routes/party_.$partyId.transfer-debt.tsx:188 +#: src/routes/party_.$partyId.transfer-debt.tsx:198 msgid "Failed to transfer debt" msgstr "" @@ -807,7 +807,7 @@ msgstr "Volver" msgid "Go Back" msgstr "Volver" -#: src/routes/party_.$partyId.transfer-debt.tsx:121 +#: src/routes/party_.$partyId.transfer-debt.tsx:131 msgid "Go back and try again from the balances list." msgstr "" @@ -1285,7 +1285,7 @@ msgstr "No se encontró cámara en este dispositivo" msgid "No currency" msgstr "Sin moneda" -#: src/routes/party_.$partyId.transfer-debt.tsx:215 +#: src/routes/party_.$partyId.transfer-debt.tsx:225 msgid "No destination party available" msgstr "" @@ -1306,7 +1306,7 @@ msgstr "No hay estadísticas de gastos para este período" msgid "No spending stats yet" msgstr "Aún no hay estadísticas de gastos" -#: src/routes/party_.$partyId.transfer-debt.tsx:291 +#: src/routes/party_.$partyId.transfer-debt.tsx:301 msgid "Nobody else is available in this party" msgstr "" @@ -1354,7 +1354,7 @@ msgstr "Rial Omaní" msgid "Only show your expenses" msgstr "Mostrar solo tus gastos" -#: src/routes/party_.$partyId.transfer-debt.tsx:131 +#: src/routes/party_.$partyId.transfer-debt.tsx:141 msgid "Only your own debt can be transferred" msgstr "" @@ -1391,7 +1391,7 @@ msgid "Other operations" msgstr "Otras operaciones" #: src/routes/party_.$partyId.pay.tsx:104 -#: src/routes/party_.$partyId.transfer-debt.tsx:380 +#: src/routes/party_.$partyId.transfer-debt.tsx:390 #: src/routes/party.$partyId.tsx:986 msgid "owes" msgstr "le debe" @@ -1618,7 +1618,7 @@ msgstr "Clasificación según cuánto ha pagado cada participante en el período msgid "Real-Time Sync" msgstr "Sincronización en Tiempo Real" -#: src/routes/party_.$partyId.transfer-debt.tsx:268 +#: src/routes/party_.$partyId.transfer-debt.tsx:278 msgid "Recommendations" msgstr "" @@ -1984,7 +1984,7 @@ msgstr "Licencias de Terceros" msgid "This application uses the following open source libraries and tools. Below are their licenses and attributions." msgstr "Esta aplicación utiliza las siguientes bibliotecas y herramientas de código abierto. A continuación se muestran sus licencias y atribuciones." -#: src/routes/party_.$partyId.transfer-debt.tsx:120 +#: src/routes/party_.$partyId.transfer-debt.tsx:130 msgid "This debt is no longer available" msgstr "" @@ -1996,7 +1996,7 @@ msgstr "Esta imagen HEIC o HEIF no se pudo procesar. Prueba con otra foto o exp msgid "This isn't a valid ID" msgstr "Este no es un ID válido" -#: src/routes/party_.$partyId.transfer-debt.tsx:292 +#: src/routes/party_.$partyId.transfer-debt.tsx:302 msgid "This party needs another active participant besides you to receive the transferred debt." msgstr "" @@ -2008,7 +2008,7 @@ msgstr "Este proyecto utiliza varias bibliotecas y herramientas de código abier msgid "This will settle the debt in <0>{0} and create the same debt in <1>{1}, where <2>{2} is owed by <3>{3}." msgstr "" -#: src/routes/party_.$partyId.transfer-debt.tsx:306 +#: src/routes/party_.$partyId.transfer-debt.tsx:316 msgid "This will settle the debt in <0>{originPartyName} and create the same debt in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} is owed by <3>{destinationCurrentParticipantName}." msgstr "" @@ -2053,15 +2053,15 @@ msgstr "Total pagado por cada participante en el período seleccionado." msgid "Total spent" msgstr "Total gastado" -#: src/routes/party_.$partyId.transfer-debt.tsx:118 -#: src/routes/party_.$partyId.transfer-debt.tsx:129 -#: src/routes/party_.$partyId.transfer-debt.tsx:204 -#: src/routes/party_.$partyId.transfer-debt.tsx:333 +#: src/routes/party_.$partyId.transfer-debt.tsx:128 +#: src/routes/party_.$partyId.transfer-debt.tsx:139 +#: src/routes/party_.$partyId.transfer-debt.tsx:214 +#: src/routes/party_.$partyId.transfer-debt.tsx:343 #: src/routes/party.$partyId.tsx:1041 msgid "Transfer debt" msgstr "" -#: src/routes/party_.$partyId.transfer-debt.tsx:186 +#: src/routes/party_.$partyId.transfer-debt.tsx:196 msgid "Transferring debt..." msgstr "" @@ -2250,7 +2250,7 @@ msgstr "Bienvenid@ a trizum" msgid "What is this party about?" msgstr "¿De qué trata este grupo?" -#: src/routes/party_.$partyId.transfer-debt.tsx:301 +#: src/routes/party_.$partyId.transfer-debt.tsx:311 msgid "What will happen" msgstr "" @@ -2266,7 +2266,7 @@ msgstr "¿Quién eres?" msgid "Who is {0} in this party?" msgstr "" -#: src/routes/party_.$partyId.transfer-debt.tsx:249 +#: src/routes/party_.$partyId.transfer-debt.tsx:259 msgid "Who is {sourceCreditorName} in this party?" msgstr "" @@ -2298,7 +2298,7 @@ msgstr "Rial Yemení" msgid "Yes! Your data is stored locally on your device and only synced with people you explicitly share your groups with. We don't have access to your expense data." msgstr "¡Sí! Tus datos se almacenan localmente en tu dispositivo y solo se sincronizan con las personas con las que compartes explícitamente tus grupos. No tenemos acceso a tus datos de gastos." -#: src/routes/party_.$partyId.transfer-debt.tsx:239 +#: src/routes/party_.$partyId.transfer-debt.tsx:249 msgid "You are" msgstr "" @@ -2306,7 +2306,7 @@ msgstr "" msgid "You can add photos to your expenses for better tracking." msgstr "Puedes añadir fotos a tus gastos para un mejor seguimiento." -#: src/routes/party_.$partyId.transfer-debt.tsx:132 +#: src/routes/party_.$partyId.transfer-debt.tsx:142 msgid "You can only transfer debt from actions where you are the one who owes the money." msgstr "" @@ -2314,7 +2314,7 @@ msgstr "" msgid "You left the party!" msgstr "¡Has dejado el grupo!" -#: src/routes/party_.$partyId.transfer-debt.tsx:216 +#: src/routes/party_.$partyId.transfer-debt.tsx:226 msgid "You need another active party with the same currency to transfer this debt." msgstr "" From 41d14e4c5c3b6c2715df6b0b08147b255d30d83b Mon Sep 17 00:00:00 2001 From: Horus Lugo Date: Sat, 18 Apr 2026 18:31:44 +0200 Subject: [PATCH 3/5] chore: trigger CI From d2e2e15639010c2267b87cf752c6b239f11ee445 Mon Sep 17 00:00:00 2001 From: Horus Lugo Date: Sun, 19 Apr 2026 00:10:04 +0200 Subject: [PATCH 4/5] Refine debt transfer flow between parties - Add review and confirmation steps for transferring debt - Update E2E page object for the new transfer flow - Refresh English and Spanish copy for the new UI --- packages/pwa/e2e/pages/transfer-debt.page.ts | 51 +- packages/pwa/locale/en/messages.po | 195 ++- packages/pwa/locale/es/messages.po | 195 ++- .../routes/party_.$partyId.transfer-debt.tsx | 1191 ++++++++++++++--- 4 files changed, 1367 insertions(+), 265 deletions(-) diff --git a/packages/pwa/e2e/pages/transfer-debt.page.ts b/packages/pwa/e2e/pages/transfer-debt.page.ts index b6ef6555..2ad2e8f0 100644 --- a/packages/pwa/e2e/pages/transfer-debt.page.ts +++ b/packages/pwa/e2e/pages/transfer-debt.page.ts @@ -2,21 +2,27 @@ import { expect, type Locator, type Page } from "@playwright/test"; export class TransferDebtPage { readonly page: Page; - readonly transferDebtButton: Locator; - readonly destinationPartySelect: Locator; + readonly selectionStep: Locator; + readonly confirmationStep: Locator; + readonly reviewTransferButton: Locator; + readonly confirmTransferButton: Locator; readonly recommendationsSection: Locator; constructor(page: Page) { this.page = page; - this.transferDebtButton = page.getByRole("button", { - name: "Transfer debt", + this.selectionStep = page.getByTestId("transfer-debt-selection-step"); + this.confirmationStep = page.getByTestId("transfer-debt-confirmation-step"); + this.reviewTransferButton = page.getByRole("button", { + name: "Review transfer", }); - this.destinationPartySelect = page.getByRole("button", { - name: "Destination party", + this.confirmTransferButton = page.getByRole("button", { + name: "Confirm transfer", }); - this.recommendationsSection = page - .locator("div.rounded-xl") - .filter({ hasText: "Recommendations" }); + this.recommendationsSection = this.selectionStep + .locator("div.rounded-3xl") + .filter({ + has: page.getByText("Quick recommendations", { exact: true }), + }); } async expectLoaded() { @@ -24,8 +30,10 @@ export class TransferDebtPage { await expect( this.page.getByRole("heading", { exact: true, name: "Transfer debt" }), ).toBeVisible(); - await expect(this.destinationPartySelect).toBeVisible(); - await expect(this.transferDebtButton).toBeVisible(); + await expect(this.selectionStep).toBeVisible(); + await expect( + this.page.getByText("Choose where the debt should continue"), + ).toBeVisible(); } async expectSearchParams(params: { @@ -46,23 +54,34 @@ export class TransferDebtPage { } async chooseDestinationParty(partyName: string) { - await this.destinationPartySelect.click(); - await this.page.getByRole("option", { name: partyName }).click(); + await this.selectionStep + .locator("button") + .filter({ hasText: partyName }) + .first() + .click(); } async expectRecommendation(participantName: string) { await expect( - this.recommendationsSection.getByRole("button", { name: participantName }), + this.recommendationsSection + .locator("button") + .filter({ hasText: participantName }) + .first(), ).toBeVisible(); } async chooseRecommendation(participantName: string) { await this.recommendationsSection - .getByRole("button", { name: participantName }) + .locator("button") + .filter({ hasText: participantName }) + .first() .click(); } async completeTransfer() { - await this.transferDebtButton.click(); + await expect(this.reviewTransferButton).toBeVisible(); + await this.reviewTransferButton.click(); + await expect(this.confirmationStep).toBeVisible(); + await this.confirmTransferButton.click(); } } diff --git a/packages/pwa/locale/en/messages.po b/packages/pwa/locale/en/messages.po index 34f20e01..4e191731 100644 --- a/packages/pwa/locale/en/messages.po +++ b/packages/pwa/locale/en/messages.po @@ -24,11 +24,24 @@ msgstr "(me)" msgid "[DEV] Create expenses" msgstr "[DEV] Create expenses" +#. placeholder {0}: option.otherParticipants.length +#: src/routes/party_.$partyId.transfer-debt.tsx:802 +msgid "{0, plural, one {# possible creditor} other {# possible creditors}}" +msgstr "{0, plural, one {# possible creditor} other {# possible creditors}}" + #. placeholder {0}: preview.remainingCount #: src/components/PartyListCard.tsx:342 msgid "{0, plural, one {and # other} other {and # others}}" msgstr "{0, plural, one {and # other} other {and # others}}" +#: src/routes/party_.$partyId.transfer-debt.tsx:967 +msgid "{destinationCreditorName} is owed by {destinationDebtorName}" +msgstr "{destinationCreditorName} is owed by {destinationDebtorName}" + +#: src/routes/party_.$partyId.transfer-debt.tsx:956 +msgid "{fromName} stops owing {toName}" +msgstr "{fromName} stops owing {toName}" + #: src/routes/migrate_.tricount.tsx:377 msgid "{message}" msgstr "{message}" @@ -243,6 +256,10 @@ msgstr "Avatar updated successfully" msgid "Azerbaijani Manat" msgstr "Azerbaijani Manat" +#: src/routes/party_.$partyId.transfer-debt.tsx:395 +msgid "Back" +msgstr "Back" + #: src/routes/archived.tsx:119 msgid "Back to home" msgstr "Back to home" @@ -291,6 +308,10 @@ msgstr "Belize Dollar" msgid "Bermudan Dollar" msgstr "Bermudan Dollar" +#: src/routes/party_.$partyId.transfer-debt.tsx:813 +msgid "Best match for {sourceCreditorName}: <0>{exactMatchParticipantName}" +msgstr "Best match for {sourceCreditorName}: <0>{exactMatchParticipantName}" + #: src/routes/new.tsx:473 msgid "Bhutanese Ngultrum" msgstr "Bhutanese Ngultrum" @@ -407,6 +428,10 @@ msgstr "Changes sync automatically across all your devices" msgid "Check for updates" msgstr "Check for updates" +#: src/routes/party_.$partyId.transfer-debt.tsx:349 +msgid "Check the parties and participants before creating the two expenses." +msgstr "Check the parties and participants before creating the two expenses." + #: src/routes/index.tsx:82 msgid "Checking for updates..." msgstr "Checking for updates..." @@ -423,6 +448,10 @@ msgstr "Chilean Unit of Account (UF)" msgid "Chinese Yuan" msgstr "Chinese Yuan" +#: src/routes/party_.$partyId.transfer-debt.tsx:648 +msgid "Choose" +msgstr "Choose" + #: src/routes/new.tsx:255 msgid "Choose the currency for expenses in this party" msgstr "Choose the currency for expenses in this party" @@ -435,6 +464,14 @@ msgstr "Choose the participant who should receive the transferred debt" msgid "Choose the party where this debt should continue" msgstr "Choose the party where this debt should continue" +#: src/routes/party_.$partyId.transfer-debt.tsx:449 +msgid "Choose where the debt should continue" +msgstr "Choose where the debt should continue" + +#: src/routes/party_.$partyId.transfer-debt.tsx:471 +msgid "Choose who receives the debt there" +msgstr "Choose who receives the debt there" + #: src/components/CalculatorToolbar.tsx:449 msgid "Clear all" msgstr "Clear all" @@ -492,6 +529,19 @@ msgstr "Complete your profile" msgid "Configure Profile" msgstr "Configure Profile" +#: src/routes/party_.$partyId.transfer-debt.tsx:348 +msgid "Confirm this transfer" +msgstr "Confirm this transfer" + +#: src/routes/party_.$partyId.transfer-debt.tsx:263 +#: src/routes/party_.$partyId.transfer-debt.tsx:422 +msgid "Confirm transfer" +msgstr "Confirm transfer" + +#: src/routes/party_.$partyId.transfer-debt.tsx:415 +msgid "Confirming..." +msgstr "Confirming..." + #: src/routes/new.tsx:477 msgid "Congolese Franc" msgstr "Congolese Franc" @@ -525,6 +575,10 @@ msgstr "Create a new Party" msgid "Created with ❤️ by the open source community. Special thanks to all contributors who made this project possible." msgstr "Created with ❤️ by the open source community. Special thanks to all contributors who made this project possible." +#: src/routes/party_.$partyId.transfer-debt.tsx:382 +msgid "Creates the same debt in the selected party" +msgstr "Creates the same debt in the selected party" + #: src/routes/about.tsx:141 msgid "Credits" msgstr "Credits" @@ -572,6 +626,10 @@ msgstr "Danish Krone" msgid "Date" msgstr "Date" +#: src/routes/party_.$partyId.transfer-debt.tsx:937 +msgid "Debt being moved" +msgstr "Debt being moved" + #: src/routes/party_.$partyId.pay.tsx:70 msgid "Debt settled between {0} and {1}!" msgstr "Debt settled between {0} and {1}!" @@ -580,15 +638,18 @@ msgstr "Debt settled between {0} and {1}!" msgid "Debt settled between {fromName} and {toName}!" msgstr "Debt settled between {fromName} and {toName}!" -#: src/routes/party_.$partyId.transfer-debt.tsx:192 +#: src/routes/party_.$partyId.transfer-debt.tsx:294 +#: src/routes/party_.$partyId.transfer-debt.tsx:381 msgid "Debt transfer from another party" msgstr "Debt transfer from another party" -#: src/routes/party_.$partyId.transfer-debt.tsx:191 +#: src/routes/party_.$partyId.transfer-debt.tsx:293 +#: src/routes/party_.$partyId.transfer-debt.tsx:375 msgid "Debt transfer to another party" msgstr "Debt transfer to another party" -#: src/routes/party_.$partyId.transfer-debt.tsx:197 +#: src/routes/party_.$partyId.transfer-debt.tsx:265 +#: src/routes/party_.$partyId.transfer-debt.tsx:1070 msgid "Debt transferred" msgstr "Debt transferred" @@ -609,7 +670,7 @@ msgstr "Description" msgid "Description must be less than 500 characters" msgstr "Description must be less than 500 characters" -#: src/routes/party_.$partyId.transfer-debt.tsx:231 +#: src/routes/party_.$partyId.transfer-debt.tsx:379 msgid "Destination party" msgstr "Destination party" @@ -629,6 +690,10 @@ msgstr "Djiboutian Franc" msgid "Dominican Peso" msgstr "Dominican Peso" +#: src/routes/party_.$partyId.transfer-debt.tsx:655 +msgid "Done" +msgstr "Done" + #: src/routes/new.tsx:590 msgid "East Caribbean Dollar" msgstr "East Caribbean Dollar" @@ -719,6 +784,14 @@ msgstr "Everything is archived for now. You can reopen any party from the archiv msgid "Exact" msgstr "Exact" +#: src/routes/party_.$partyId.transfer-debt.tsx:876 +msgid "Exact match" +msgstr "Exact match" + +#: src/routes/party_.$partyId.transfer-debt.tsx:479 +msgid "Exact name match selected automatically: {selectedExactMatchParticipantName}" +msgstr "Exact name match selected automatically: {selectedExactMatchParticipantName}" + #: src/routes/party_.$partyId.add.tsx:97 msgid "Expense added" msgstr "Expense added" @@ -743,6 +816,10 @@ msgstr "Expenses" msgid "Expenses counted" msgstr "Expenses counted" +#: src/routes/party_.$partyId.transfer-debt.tsx:367 +msgid "Expenses that will be created" +msgstr "Expenses that will be created" + #: src/components/QRCodeScanner.tsx:120 msgid "Failed to access camera" msgstr "Failed to access camera" @@ -775,7 +852,7 @@ msgstr "Failed to load licenses. Please try again later." msgid "Failed to mark expense as paid" msgstr "Failed to mark expense as paid" -#: src/routes/party_.$partyId.transfer-debt.tsx:198 +#: src/routes/party_.$partyId.transfer-debt.tsx:301 msgid "Failed to transfer debt" msgstr "Failed to transfer debt" @@ -851,7 +928,7 @@ msgstr "Go back" msgid "Go Back" msgstr "Go Back" -#: src/routes/party_.$partyId.transfer-debt.tsx:131 +#: src/routes/party_.$partyId.transfer-debt.tsx:235 msgid "Go back and try again from the balances list." msgstr "Go back and try again from the balances list." @@ -1119,6 +1196,10 @@ msgstr "Libyan Dinar" msgid "License" msgstr "License" +#: src/routes/party_.$partyId.transfer-debt.tsx:820 +msgid "Likely match for {sourceCreditorName}: <0>{topRecommendationName}" +msgstr "Likely match for {sourceCreditorName}: <0>{topRecommendationName}" + #: src/routes/join.tsx:179 msgid "Link or code" msgstr "Link or code" @@ -1222,7 +1303,7 @@ msgid "Migration successful" msgstr "Migration successful" #: src/routes/party_.$partyId.pay.tsx:24 -#: src/routes/party_.$partyId.transfer-debt.tsx:39 +#: src/routes/party_.$partyId.transfer-debt.tsx:58 msgid "Missing search params" msgstr "Missing search params" @@ -1337,7 +1418,7 @@ msgstr "No camera found on this device" msgid "No currency" msgstr "No currency" -#: src/routes/party_.$partyId.transfer-debt.tsx:225 +#: src/routes/party_.$partyId.transfer-debt.tsx:442 msgid "No destination party available" msgstr "No destination party available" @@ -1345,6 +1426,10 @@ msgstr "No destination party available" msgid "No emoji found" msgstr "No emoji found" +#: src/routes/party_.$partyId.transfer-debt.tsx:825 +msgid "No obvious match for {sourceCreditorName} yet" +msgstr "No obvious match for {sourceCreditorName} yet" + #: src/components/PartyStatsView.tsx:299 #: src/components/PartyStatsView.tsx:513 msgid "No spending in this timeframe" @@ -1362,7 +1447,7 @@ msgstr "No spending stats yet" msgid "No updates available" msgstr "No updates available" -#: src/routes/party_.$partyId.transfer-debt.tsx:301 +#: src/routes/party_.$partyId.transfer-debt.tsx:525 msgid "Nobody else is available in this party" msgstr "Nobody else is available in this party" @@ -1410,7 +1495,7 @@ msgstr "Omani Rial" msgid "Only show your expenses" msgstr "Only show your expenses" -#: src/routes/party_.$partyId.transfer-debt.tsx:141 +#: src/routes/party_.$partyId.transfer-debt.tsx:245 msgid "Only your own debt can be transferred" msgstr "Only your own debt can be transferred" @@ -1438,6 +1523,10 @@ msgstr "Open last party on launch" msgid "Open parenthesis" msgstr "Open parenthesis" +#: src/routes/party_.$partyId.transfer-debt.tsx:1085 +msgid "Opening updated expense…" +msgstr "Opening updated expense…" + #: src/components/ExpenseEditor.tsx:201 msgid "Optional description for this expense" msgstr "Optional description for this expense" @@ -1446,12 +1535,16 @@ msgstr "Optional description for this expense" msgid "or enter code" msgstr "or enter code" +#: src/routes/party_.$partyId.transfer-debt.tsx:373 +msgid "Origin party" +msgstr "Origin party" + #: src/routes/party.$partyId.tsx:874 msgid "Other operations" msgstr "Other operations" #: src/routes/party_.$partyId.pay.tsx:104 -#: src/routes/party_.$partyId.transfer-debt.tsx:390 +#: src/routes/party_.$partyId.transfer-debt.tsx:723 #: src/routes/party.$partyId.tsx:986 msgid "owes" msgstr "owes" @@ -1517,6 +1610,10 @@ msgstr "participants" msgid "Participants" msgstr "Participants" +#: src/routes/party_.$partyId.transfer-debt.tsx:450 +msgid "Parties show your identity there and the best match we can find for {sourceCreditorName}." +msgstr "Parties show your identity there and the best match we can find for {sourceCreditorName}." + #: src/components/PartyListCard.tsx:248 msgid "Party actions" msgstr "Party actions" @@ -1614,6 +1711,10 @@ msgstr "Phone number is required" msgid "Phone number must be less than 20 characters" msgstr "Phone number must be less than 20 characters" +#: src/routes/party_.$partyId.transfer-debt.tsx:472 +msgid "Pick the person in {destinationPartyName} who should be owed after the transfer." +msgstr "Pick the person in {destinationPartyName} who should be owed after the transfer." + #: src/routes/index.tsx:312 msgid "Pin party" msgstr "Pin party" @@ -1666,6 +1767,10 @@ msgstr "Point your camera at a trizum QR code" msgid "Polish Zloty" msgstr "Polish Zloty" +#: src/routes/party_.$partyId.transfer-debt.tsx:561 +msgid "Preview" +msgstr "Preview" + #: src/components/MediaGallery.tsx:194 msgid "Previous" msgstr "Previous" @@ -1678,6 +1783,10 @@ msgstr "Privacy Policy" msgid "Qatari Rial" msgstr "Qatari Rial" +#: src/routes/party_.$partyId.transfer-debt.tsx:490 +msgid "Quick recommendations" +msgstr "Quick recommendations" + #: src/components/PartyStatsView.tsx:240 msgid "Ranking based on how much each participant paid in the selected timeframe." msgstr "Ranking based on how much each participant paid in the selected timeframe." @@ -1694,6 +1803,14 @@ msgstr "Receipt Attachments" msgid "Recommendations" msgstr "Recommendations" +#: src/routes/party_.$partyId.transfer-debt.tsx:885 +msgid "Recommended" +msgstr "Recommended" + +#: src/routes/party_.$partyId.transfer-debt.tsx:964 +msgid "Recreated in" +msgstr "Recreated in" + #: src/components/UpdateController.tsx:28 msgid "Reload" msgstr "Reload" @@ -1731,6 +1848,15 @@ msgstr "Restore to home" msgid "Retry" msgstr "Retry" +#: src/routes/party_.$partyId.transfer-debt.tsx:347 +#: src/routes/party_.$partyId.transfer-debt.tsx:652 +msgid "Review" +msgstr "Review" + +#: src/routes/party_.$partyId.transfer-debt.tsx:603 +msgid "Review transfer" +msgstr "Review transfer" + #: src/routes/new.tsx:441 msgid "Romanian Leu" msgstr "Romanian Leu" @@ -1793,6 +1919,10 @@ msgstr "See spending totals and rankings" msgid "Select emoji" msgstr "Select emoji" +#: src/routes/party_.$partyId.transfer-debt.tsx:894 +msgid "Selected" +msgstr "Selected" + #: src/routes/support.tsx:52 msgid "Send us an email and we'll get back to you as soon as possible." msgstr "Send us an email and we'll get back to you as soon as possible." @@ -1815,6 +1945,14 @@ msgstr "Settings" msgid "Settings saved" msgstr "Settings saved" +#: src/routes/party_.$partyId.transfer-debt.tsx:953 +msgid "Settled in" +msgstr "Settled in" + +#: src/routes/party_.$partyId.transfer-debt.tsx:376 +msgid "Settles the current debt" +msgstr "Settles the current debt" + #: src/routes/new.tsx:554 msgid "Seychellois Rupee" msgstr "Seychellois Rupee" @@ -1926,6 +2064,14 @@ msgstr "Start tracking expenses with your group" msgid "Stats" msgstr "Stats" +#: src/routes/party_.$partyId.transfer-debt.tsx:448 +msgid "Step 1" +msgstr "Step 1" + +#: src/routes/party_.$partyId.transfer-debt.tsx:470 +msgid "Step 2" +msgstr "Step 2" + #: src/routes/support.tsx:89 msgid "Submit a Request" msgstr "Submit a Request" @@ -2038,6 +2184,10 @@ msgstr "Thank you for using trizum. Your feedback helps us improve!" msgid "The app works offline too!" msgstr "The app works offline too!" +#: src/routes/party_.$partyId.transfer-debt.tsx:1074 +msgid "The new debt is now in <0>{destinationPartyName}, where <1>{destinationCounterpartyName} is owed by <2>{destinationDebtorName}." +msgstr "The new debt is now in <0>{destinationPartyName}, where <1>{destinationCounterpartyName} is owed by <2>{destinationDebtorName}." + #: src/components/PartyPendingComponent.tsx:27 msgid "The party data hasn't synced to the sync server yet." msgstr "The party data hasn't synced to the sync server yet." @@ -2060,7 +2210,7 @@ msgstr "Third-Party Licenses" msgid "This application uses the following open source libraries and tools. Below are their licenses and attributions." msgstr "This application uses the following open source libraries and tools. Below are their licenses and attributions." -#: src/routes/party_.$partyId.transfer-debt.tsx:130 +#: src/routes/party_.$partyId.transfer-debt.tsx:234 msgid "This debt is no longer available" msgstr "This debt is no longer available" @@ -2072,7 +2222,7 @@ msgstr "This HEIC or HEIF image could not be processed. Try another photo or exp msgid "This isn't a valid ID" msgstr "This isn't a valid ID" -#: src/routes/party_.$partyId.transfer-debt.tsx:302 +#: src/routes/party_.$partyId.transfer-debt.tsx:526 msgid "This party needs another active participant besides you to receive the transferred debt." msgstr "This party needs another active participant besides you to receive the transferred debt." @@ -2088,6 +2238,10 @@ msgstr "This will settle the debt in <0>{0} and create the same debt in <1>{ msgid "This will settle the debt in <0>{originPartyName} and create the same debt in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} is owed by <3>{destinationCurrentParticipantName}." msgstr "This will settle the debt in <0>{originPartyName} and create the same debt in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} is owed by <3>{destinationCurrentParticipantName}." +#: src/routes/party_.$partyId.transfer-debt.tsx:566 +msgid "This will settle the debt in <0>{originPartyName} and recreate it in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} will be owed by <3>{destinationCurrentParticipantName}." +msgstr "This will settle the debt in <0>{originPartyName} and recreate it in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} will be owed by <3>{destinationCurrentParticipantName}." + #. Label for the timeframe filter on the party stats screen #: src/components/PartyStatsView.tsx:270 msgid "Timeframe" @@ -2133,10 +2287,9 @@ msgstr "Total spent" msgid "Total split:" msgstr "Total split:" -#: src/routes/party_.$partyId.transfer-debt.tsx:128 -#: src/routes/party_.$partyId.transfer-debt.tsx:139 -#: src/routes/party_.$partyId.transfer-debt.tsx:214 -#: src/routes/party_.$partyId.transfer-debt.tsx:343 +#: src/routes/party_.$partyId.transfer-debt.tsx:232 +#: src/routes/party_.$partyId.transfer-debt.tsx:243 +#: src/routes/party_.$partyId.transfer-debt.tsx:266 #: src/routes/party.$partyId.tsx:1041 msgid "Transfer debt" msgstr "Transfer debt" @@ -2403,11 +2556,15 @@ msgstr "Yes! Your data is stored locally on your device and only synced with peo msgid "You are" msgstr "You are" +#: src/routes/party_.$partyId.transfer-debt.tsx:795 +msgid "You are {currentParticipantName}" +msgstr "You are {currentParticipantName}" + #: src/components/PartyPendingComponent.tsx:16 msgid "You can add photos to your expenses for better tracking." msgstr "You can add photos to your expenses for better tracking." -#: src/routes/party_.$partyId.transfer-debt.tsx:142 +#: src/routes/party_.$partyId.transfer-debt.tsx:246 msgid "You can only transfer debt from actions where you are the one who owes the money." msgstr "You can only transfer debt from actions where you are the one who owes the money." @@ -2415,7 +2572,7 @@ msgstr "You can only transfer debt from actions where you are the one who owes t msgid "You left the party!" msgstr "You left the party!" -#: src/routes/party_.$partyId.transfer-debt.tsx:226 +#: src/routes/party_.$partyId.transfer-debt.tsx:443 msgid "You need another active party with the same currency to transfer this debt." msgstr "You need another active party with the same currency to transfer this debt." diff --git a/packages/pwa/locale/es/messages.po b/packages/pwa/locale/es/messages.po index 26040c29..eeb289a2 100644 --- a/packages/pwa/locale/es/messages.po +++ b/packages/pwa/locale/es/messages.po @@ -24,11 +24,24 @@ msgstr "(yo)" msgid "[DEV] Create expenses" msgstr "[DEV] Crear gastos" +#. placeholder {0}: option.otherParticipants.length +#: src/routes/party_.$partyId.transfer-debt.tsx:802 +msgid "{0, plural, one {# possible creditor} other {# possible creditors}}" +msgstr "" + #. placeholder {0}: preview.remainingCount #: src/components/PartyListCard.tsx:342 msgid "{0, plural, one {and # other} other {and # others}}" msgstr "{0, plural, one {y # más} other {y # más}}" +#: src/routes/party_.$partyId.transfer-debt.tsx:967 +msgid "{destinationCreditorName} is owed by {destinationDebtorName}" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:956 +msgid "{fromName} stops owing {toName}" +msgstr "" + #: src/routes/migrate_.tricount.tsx:377 msgid "{message}" msgstr "{message}" @@ -231,6 +244,10 @@ msgstr "Avatar actualizado correctamente" msgid "Azerbaijani Manat" msgstr "Manat Azerbaiyano" +#: src/routes/party_.$partyId.transfer-debt.tsx:395 +msgid "Back" +msgstr "" + #: src/routes/archived.tsx:119 msgid "Back to home" msgstr "Volver al inicio" @@ -279,6 +296,10 @@ msgstr "Dólar Beliceño" msgid "Bermudan Dollar" msgstr "Dólar Bermudeño" +#: src/routes/party_.$partyId.transfer-debt.tsx:813 +msgid "Best match for {sourceCreditorName}: <0>{exactMatchParticipantName}" +msgstr "" + #: src/routes/new.tsx:473 msgid "Bhutanese Ngultrum" msgstr "Ngultrum Butanés" @@ -387,6 +408,10 @@ msgstr "Los cambios se sincronizan automáticamente en todos tus dispositivos" msgid "Check for updates" msgstr "Comprobar actualizaciones" +#: src/routes/party_.$partyId.transfer-debt.tsx:349 +msgid "Check the parties and participants before creating the two expenses." +msgstr "" + #: src/routes/new.tsx:481 msgid "Chilean Peso" msgstr "Peso Chileno" @@ -399,6 +424,10 @@ msgstr "Unidad de Fomento Chilena (UF)" msgid "Chinese Yuan" msgstr "Yuan Chino" +#: src/routes/party_.$partyId.transfer-debt.tsx:648 +msgid "Choose" +msgstr "" + #: src/routes/new.tsx:255 msgid "Choose the currency for expenses in this party" msgstr "Elige la moneda para los gastos de este grupo" @@ -411,6 +440,14 @@ msgstr "" msgid "Choose the party where this debt should continue" msgstr "" +#: src/routes/party_.$partyId.transfer-debt.tsx:449 +msgid "Choose where the debt should continue" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:471 +msgid "Choose who receives the debt there" +msgstr "" + #: src/components/CalculatorToolbar.tsx:449 msgid "Clear all" msgstr "Borrar todo" @@ -468,6 +505,19 @@ msgstr "Completa tu perfil" msgid "Configure Profile" msgstr "Configurar perfil" +#: src/routes/party_.$partyId.transfer-debt.tsx:348 +msgid "Confirm this transfer" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:263 +#: src/routes/party_.$partyId.transfer-debt.tsx:422 +msgid "Confirm transfer" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:415 +msgid "Confirming..." +msgstr "" + #: src/routes/new.tsx:477 msgid "Congolese Franc" msgstr "Franco Congoleño" @@ -497,6 +547,10 @@ msgstr "Colón Costarricense" msgid "Create a new Party" msgstr "Crear un nuevo Grupo" +#: src/routes/party_.$partyId.transfer-debt.tsx:382 +msgid "Creates the same debt in the selected party" +msgstr "" + #: src/routes/about.tsx:141 msgid "Credits" msgstr "Créditos" @@ -544,6 +598,10 @@ msgstr "Corona Danesa" msgid "Date" msgstr "Fecha" +#: src/routes/party_.$partyId.transfer-debt.tsx:937 +msgid "Debt being moved" +msgstr "" + #: src/routes/party_.$partyId.pay.tsx:70 msgid "Debt settled between {0} and {1}!" msgstr "¡Deuda saldada entre {0} y {1}!" @@ -552,15 +610,18 @@ msgstr "¡Deuda saldada entre {0} y {1}!" msgid "Debt settled between {fromName} and {toName}!" msgstr "¡Deuda saldada entre {fromName} y {toName}!" -#: src/routes/party_.$partyId.transfer-debt.tsx:192 +#: src/routes/party_.$partyId.transfer-debt.tsx:294 +#: src/routes/party_.$partyId.transfer-debt.tsx:381 msgid "Debt transfer from another party" msgstr "" -#: src/routes/party_.$partyId.transfer-debt.tsx:191 +#: src/routes/party_.$partyId.transfer-debt.tsx:293 +#: src/routes/party_.$partyId.transfer-debt.tsx:375 msgid "Debt transfer to another party" msgstr "" -#: src/routes/party_.$partyId.transfer-debt.tsx:197 +#: src/routes/party_.$partyId.transfer-debt.tsx:265 +#: src/routes/party_.$partyId.transfer-debt.tsx:1070 msgid "Debt transferred" msgstr "" @@ -581,7 +642,7 @@ msgstr "Descripción" msgid "Description must be less than 500 characters" msgstr "La descripción debe tener menos de 500 caracteres" -#: src/routes/party_.$partyId.transfer-debt.tsx:231 +#: src/routes/party_.$partyId.transfer-debt.tsx:379 msgid "Destination party" msgstr "" @@ -601,6 +662,10 @@ msgstr "Franco Yibutiano" msgid "Dominican Peso" msgstr "Peso Dominicano" +#: src/routes/party_.$partyId.transfer-debt.tsx:655 +msgid "Done" +msgstr "" + #: src/routes/new.tsx:590 msgid "East Caribbean Dollar" msgstr "Dólar del Caribe Oriental" @@ -683,6 +748,14 @@ msgstr "Unidad de Cuenta Europea 9" msgid "Everything is archived for now. You can reopen any party from the archived screen whenever you need it." msgstr "Por ahora todo está archivado. Puedes reabrir cualquier grupo desde la pantalla de archivados cuando lo necesites." +#: src/routes/party_.$partyId.transfer-debt.tsx:876 +msgid "Exact match" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:479 +msgid "Exact name match selected automatically: {selectedExactMatchParticipantName}" +msgstr "" + #: src/routes/party_.$partyId.add.tsx:97 msgid "Expense added" msgstr "Gasto añadido" @@ -703,6 +776,10 @@ msgstr "Gastos" msgid "Expenses counted" msgstr "Gastos contabilizados" +#: src/routes/party_.$partyId.transfer-debt.tsx:367 +msgid "Expenses that will be created" +msgstr "" + #: src/components/QRCodeScanner.tsx:120 msgid "Failed to access camera" msgstr "Error al acceder a la cámara" @@ -731,7 +808,7 @@ msgstr "Error al cargar las licencias. Por favor, inténtalo más tarde." msgid "Failed to mark expense as paid" msgstr "Error al marcar el gasto como pagado" -#: src/routes/party_.$partyId.transfer-debt.tsx:198 +#: src/routes/party_.$partyId.transfer-debt.tsx:301 msgid "Failed to transfer debt" msgstr "" @@ -807,7 +884,7 @@ msgstr "Volver" msgid "Go Back" msgstr "Volver" -#: src/routes/party_.$partyId.transfer-debt.tsx:131 +#: src/routes/party_.$partyId.transfer-debt.tsx:235 msgid "Go back and try again from the balances list." msgstr "" @@ -1067,6 +1144,10 @@ msgstr "Dinar Libio" msgid "License" msgstr "Licencia" +#: src/routes/party_.$partyId.transfer-debt.tsx:820 +msgid "Likely match for {sourceCreditorName}: <0>{topRecommendationName}" +msgstr "" + #: src/routes/join.tsx:179 msgid "Link or code" msgstr "Enlace o código" @@ -1170,7 +1251,7 @@ msgid "Migration successful" msgstr "Migración exitosa" #: src/routes/party_.$partyId.pay.tsx:24 -#: src/routes/party_.$partyId.transfer-debt.tsx:39 +#: src/routes/party_.$partyId.transfer-debt.tsx:58 msgid "Missing search params" msgstr "Faltan parámetros de búsqueda" @@ -1285,7 +1366,7 @@ msgstr "No se encontró cámara en este dispositivo" msgid "No currency" msgstr "Sin moneda" -#: src/routes/party_.$partyId.transfer-debt.tsx:225 +#: src/routes/party_.$partyId.transfer-debt.tsx:442 msgid "No destination party available" msgstr "" @@ -1293,6 +1374,10 @@ msgstr "" msgid "No emoji found" msgstr "No se encontraron emojis" +#: src/routes/party_.$partyId.transfer-debt.tsx:825 +msgid "No obvious match for {sourceCreditorName} yet" +msgstr "" + #: src/components/PartyStatsView.tsx:299 #: src/components/PartyStatsView.tsx:513 msgid "No spending in this timeframe" @@ -1306,7 +1391,7 @@ msgstr "No hay estadísticas de gastos para este período" msgid "No spending stats yet" msgstr "Aún no hay estadísticas de gastos" -#: src/routes/party_.$partyId.transfer-debt.tsx:301 +#: src/routes/party_.$partyId.transfer-debt.tsx:525 msgid "Nobody else is available in this party" msgstr "" @@ -1354,7 +1439,7 @@ msgstr "Rial Omaní" msgid "Only show your expenses" msgstr "Mostrar solo tus gastos" -#: src/routes/party_.$partyId.transfer-debt.tsx:141 +#: src/routes/party_.$partyId.transfer-debt.tsx:245 msgid "Only your own debt can be transferred" msgstr "" @@ -1382,16 +1467,24 @@ msgstr "Abrir el último grupo al abrir la app" msgid "Open parenthesis" msgstr "Abrir paréntesis" +#: src/routes/party_.$partyId.transfer-debt.tsx:1085 +msgid "Opening updated expense…" +msgstr "" + #: src/routes/join.tsx:157 msgid "or enter code" msgstr "o introduce código" +#: src/routes/party_.$partyId.transfer-debt.tsx:373 +msgid "Origin party" +msgstr "" + #: src/routes/party.$partyId.tsx:874 msgid "Other operations" msgstr "Otras operaciones" #: src/routes/party_.$partyId.pay.tsx:104 -#: src/routes/party_.$partyId.transfer-debt.tsx:390 +#: src/routes/party_.$partyId.transfer-debt.tsx:723 #: src/routes/party.$partyId.tsx:986 msgid "owes" msgstr "le debe" @@ -1457,6 +1550,10 @@ msgstr "participantes" msgid "Participants" msgstr "Participantes" +#: src/routes/party_.$partyId.transfer-debt.tsx:450 +msgid "Parties show your identity there and the best match we can find for {sourceCreditorName}." +msgstr "" + #: src/components/PartyListCard.tsx:248 msgid "Party actions" msgstr "Acciones del grupo" @@ -1546,6 +1643,10 @@ msgstr "Se requiere el número de teléfono" msgid "Phone number must be less than 20 characters" msgstr "El número de teléfono debe tener menos de 20 caracteres" +#: src/routes/party_.$partyId.transfer-debt.tsx:472 +msgid "Pick the person in {destinationPartyName} who should be owed after the transfer." +msgstr "" + #: src/routes/index.tsx:312 msgid "Pin party" msgstr "Fijar grupo" @@ -1598,6 +1699,10 @@ msgstr "Apunta tu cámara a un código QR de trizum" msgid "Polish Zloty" msgstr "Zloty Polaco" +#: src/routes/party_.$partyId.transfer-debt.tsx:561 +msgid "Preview" +msgstr "" + #: src/components/MediaGallery.tsx:194 msgid "Previous" msgstr "Anterior" @@ -1610,6 +1715,10 @@ msgstr "Política de Privacidad" msgid "Qatari Rial" msgstr "Rial Catarí" +#: src/routes/party_.$partyId.transfer-debt.tsx:490 +msgid "Quick recommendations" +msgstr "" + #: src/components/PartyStatsView.tsx:240 msgid "Ranking based on how much each participant paid in the selected timeframe." msgstr "Clasificación según cuánto ha pagado cada participante en el período seleccionado." @@ -1622,6 +1731,14 @@ msgstr "Sincronización en Tiempo Real" msgid "Recommendations" msgstr "" +#: src/routes/party_.$partyId.transfer-debt.tsx:885 +msgid "Recommended" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:964 +msgid "Recreated in" +msgstr "" + #: src/components/UpdateController.tsx:28 msgid "Reload" msgstr "Recargar" @@ -1659,6 +1776,15 @@ msgstr "Restaurar al inicio" msgid "Retry" msgstr "Reintentar" +#: src/routes/party_.$partyId.transfer-debt.tsx:347 +#: src/routes/party_.$partyId.transfer-debt.tsx:652 +msgid "Review" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:603 +msgid "Review transfer" +msgstr "" + #: src/routes/new.tsx:441 msgid "Romanian Leu" msgstr "Leu Rumano" @@ -1721,6 +1847,10 @@ msgstr "Ver totales y clasificación de gasto" msgid "Select emoji" msgstr "Seleccionar emoji" +#: src/routes/party_.$partyId.transfer-debt.tsx:894 +msgid "Selected" +msgstr "" + #: src/routes/support.tsx:52 msgid "Send us an email and we'll get back to you as soon as possible." msgstr "Envíanos un email y te responderemos lo antes posible." @@ -1743,6 +1873,14 @@ msgstr "Configuración" msgid "Settings saved" msgstr "Configuración guardada" +#: src/routes/party_.$partyId.transfer-debt.tsx:953 +msgid "Settled in" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:376 +msgid "Settles the current debt" +msgstr "" + #: src/routes/new.tsx:554 msgid "Seychellois Rupee" msgstr "Rupia de Seychelles" @@ -1850,6 +1988,14 @@ msgstr "Comienza a registrar gastos con tu grupo" msgid "Stats" msgstr "Estadísticas" +#: src/routes/party_.$partyId.transfer-debt.tsx:448 +msgid "Step 1" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:470 +msgid "Step 2" +msgstr "" + #: src/routes/support.tsx:89 msgid "Submit a Request" msgstr "Enviar sugerencia" @@ -1962,6 +2108,10 @@ msgstr "Gracias por usar trizum. ¡Tus comentarios nos ayudan a mejorar!" msgid "The app works offline too!" msgstr "¡La app también funciona sin conexión!" +#: src/routes/party_.$partyId.transfer-debt.tsx:1074 +msgid "The new debt is now in <0>{destinationPartyName}, where <1>{destinationCounterpartyName} is owed by <2>{destinationDebtorName}." +msgstr "" + #: src/components/PartyPendingComponent.tsx:27 msgid "The party data hasn't synced to the sync server yet." msgstr "Los datos del grupo aún no se han sincronizado con el servidor." @@ -1984,7 +2134,7 @@ msgstr "Licencias de Terceros" msgid "This application uses the following open source libraries and tools. Below are their licenses and attributions." msgstr "Esta aplicación utiliza las siguientes bibliotecas y herramientas de código abierto. A continuación se muestran sus licencias y atribuciones." -#: src/routes/party_.$partyId.transfer-debt.tsx:130 +#: src/routes/party_.$partyId.transfer-debt.tsx:234 msgid "This debt is no longer available" msgstr "" @@ -1996,7 +2146,7 @@ msgstr "Esta imagen HEIC o HEIF no se pudo procesar. Prueba con otra foto o exp msgid "This isn't a valid ID" msgstr "Este no es un ID válido" -#: src/routes/party_.$partyId.transfer-debt.tsx:302 +#: src/routes/party_.$partyId.transfer-debt.tsx:526 msgid "This party needs another active participant besides you to receive the transferred debt." msgstr "" @@ -2012,6 +2162,10 @@ msgstr "" msgid "This will settle the debt in <0>{originPartyName} and create the same debt in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} is owed by <3>{destinationCurrentParticipantName}." msgstr "" +#: src/routes/party_.$partyId.transfer-debt.tsx:566 +msgid "This will settle the debt in <0>{originPartyName} and recreate it in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} will be owed by <3>{destinationCurrentParticipantName}." +msgstr "" + #. Label for the timeframe filter on the party stats screen #: src/components/PartyStatsView.tsx:270 msgid "Timeframe" @@ -2053,10 +2207,9 @@ msgstr "Total pagado por cada participante en el período seleccionado." msgid "Total spent" msgstr "Total gastado" -#: src/routes/party_.$partyId.transfer-debt.tsx:128 -#: src/routes/party_.$partyId.transfer-debt.tsx:139 -#: src/routes/party_.$partyId.transfer-debt.tsx:214 -#: src/routes/party_.$partyId.transfer-debt.tsx:343 +#: src/routes/party_.$partyId.transfer-debt.tsx:232 +#: src/routes/party_.$partyId.transfer-debt.tsx:243 +#: src/routes/party_.$partyId.transfer-debt.tsx:266 #: src/routes/party.$partyId.tsx:1041 msgid "Transfer debt" msgstr "" @@ -2302,11 +2455,15 @@ msgstr "¡Sí! Tus datos se almacenan localmente en tu dispositivo y solo se sin msgid "You are" msgstr "" +#: src/routes/party_.$partyId.transfer-debt.tsx:795 +msgid "You are {currentParticipantName}" +msgstr "" + #: src/components/PartyPendingComponent.tsx:16 msgid "You can add photos to your expenses for better tracking." msgstr "Puedes añadir fotos a tus gastos para un mejor seguimiento." -#: src/routes/party_.$partyId.transfer-debt.tsx:142 +#: src/routes/party_.$partyId.transfer-debt.tsx:246 msgid "You can only transfer debt from actions where you are the one who owes the money." msgstr "" @@ -2314,7 +2471,7 @@ msgstr "" msgid "You left the party!" msgstr "¡Has dejado el grupo!" -#: src/routes/party_.$partyId.transfer-debt.tsx:226 +#: src/routes/party_.$partyId.transfer-debt.tsx:443 msgid "You need another active party with the same currency to transfer this debt." msgstr "" diff --git a/packages/pwa/src/routes/party_.$partyId.transfer-debt.tsx b/packages/pwa/src/routes/party_.$partyId.transfer-debt.tsx index a4610cc1..5f1bb285 100644 --- a/packages/pwa/src/routes/party_.$partyId.transfer-debt.tsx +++ b/packages/pwa/src/routes/party_.$partyId.transfer-debt.tsx @@ -1,24 +1,31 @@ -import { Trans } from "@lingui/react/macro"; import { t } from "@lingui/core/macro"; +import { Plural, Trans } from "@lingui/react/macro"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import type { Currency } from "dinero.js"; -import { useEffect, useMemo, useState } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { Suspense, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { BackButton } from "#src/components/BackButton.tsx"; import { CurrencyText } from "#src/components/CurrencyText.tsx"; import { PartyPendingComponent } from "#src/components/PartyPendingComponent.tsx"; import { useCurrentParticipant } from "#src/hooks/useCurrentParticipant.ts"; -import { useEligibleDebtTransferParties } from "#src/hooks/useEligibleDebtTransferParties.ts"; +import { + type EligibleDebtTransferParty, + useEligibleDebtTransferParties, +} from "#src/hooks/useEligibleDebtTransferParties.ts"; +import { useMediaFile } from "#src/hooks/useMediaFile.ts"; import { useCurrentParty } from "#src/hooks/useParty.ts"; import { getDebtTransferParticipantMatch, type DebtTransferParticipantMatch, } from "#src/lib/debtTransfer.ts"; import { guardParticipatingInParty } from "#src/lib/guards.ts"; +import type { Party, PartyParticipant } from "#src/models/party.ts"; import { Alert, AlertDescription, AlertTitle } from "#src/ui/Alert.tsx"; +import { Avatar } from "#src/ui/Avatar.tsx"; import { Button } from "#src/ui/Button.tsx"; import { Icon } from "#src/ui/Icon.tsx"; -import { AppSelect, SelectItem } from "#src/ui/Select.tsx"; +import { cn } from "#src/ui/utils.ts"; interface TransferDebtSearchParams { fromId: string; @@ -26,6 +33,18 @@ interface TransferDebtSearchParams { amount: number; } +type TransferStep = "configure" | "confirm" | "success"; + +interface DestinationPartyOption { + id: string; + entry: EligibleDebtTransferParty; + currentParticipant: PartyParticipant; + otherParticipants: PartyParticipant[]; + participantMatch: DebtTransferParticipantMatch; + exactMatchParticipant: PartyParticipant | null; + recommendedParticipants: PartyParticipant[]; +} + export const Route = createFileRoute("/party_/$partyId/transfer-debt")({ component: RouteComponent, pendingComponent: PartyPendingComponent, @@ -61,67 +80,152 @@ function RouteComponent() { const to = party.participants[toId]; const isSupportedTransfer = fromId === currentParticipant.id; - const [destinationPartyId, setDestinationPartyId] = useState(() => - eligibleDestinationParties.length === 1 - ? eligibleDestinationParties[0].party.id - : "", - ); + const [destinationPartyId, setDestinationPartyId] = useState(""); const [destinationParticipantId, setDestinationParticipantId] = useState(""); + const [dismissedRecommendationIds, setDismissedRecommendationIds] = useState< + string[] + >([]); + const [step, setStep] = useState("configure"); + const [isSubmitting, setIsSubmitting] = useState(false); + const [successExpenseId, setSuccessExpenseId] = useState(null); - const destinationPartyOptions = useMemo( - () => - eligibleDestinationParties.map((entry) => ({ - id: entry.party.id, - entry, - })), - [eligibleDestinationParties], - ); + const destinationPartyOptions = useMemo(() => { + return eligibleDestinationParties.flatMap((entry) => { + const currentDestinationParticipant = + entry.party.participants[entry.currentParticipantId]; + + if ( + !currentDestinationParticipant || + currentDestinationParticipant.isArchived + ) { + return []; + } + + const otherParticipants = Object.values(entry.party.participants) + .filter( + (participant) => + !participant.isArchived && + participant.id !== currentDestinationParticipant.id, + ) + .sort((left, right) => left.name.localeCompare(right.name)); + const participantMatch = getDebtTransferParticipantMatch({ + sourceName: to?.name ?? "", + participants: otherParticipants, + }); + const exactMatchParticipant = + otherParticipants.find( + (participant) => + participant.id === participantMatch.exactMatchParticipantId, + ) ?? null; + const recommendedParticipants = participantMatch.recommendedParticipantIds + .map((participantId) => + otherParticipants.find( + (participant) => participant.id === participantId, + ), + ) + .filter( + (participant): participant is PartyParticipant => !!participant, + ); + + return [ + { + id: entry.party.id, + entry, + currentParticipant: currentDestinationParticipant, + otherParticipants, + participantMatch, + exactMatchParticipant, + recommendedParticipants, + }, + ]; + }); + }, [eligibleDestinationParties, to?.name]); const selectedDestinationParty = destinationPartyOptions.find( ({ id }) => id === destinationPartyId, ); + const destinationParticipants = + selectedDestinationParty?.otherParticipants ?? []; + const selectedDestinationCounterparty = destinationParticipants.find( + (participant) => participant.id === destinationParticipantId, + ); + const displayedRecommendedParticipants = + selectedDestinationParty?.recommendedParticipants.filter( + (participant) => !dismissedRecommendationIds.includes(participant.id), + ) ?? []; + const canTransfer = + !!selectedDestinationParty && + !!selectedDestinationCounterparty && + destinationParticipantId !== ""; - const destinationParticipants = useMemo(() => { - if (!selectedDestinationParty) { - return []; + useEffect(() => { + if (destinationPartyOptions.length === 0) { + setDestinationPartyId(""); + return; } - return Object.values(selectedDestinationParty.entry.party.participants) - .filter( - (participant) => - !participant.isArchived && - participant.id !== - selectedDestinationParty.entry.currentParticipantId, - ) - .sort((left, right) => left.name.localeCompare(right.name)); - }, [selectedDestinationParty]); + if ( + destinationPartyId && + destinationPartyOptions.some((option) => option.id === destinationPartyId) + ) { + return; + } - const participantMatch = useMemo(() => { - return getDebtTransferParticipantMatch({ - sourceName: to?.name ?? "", - participants: destinationParticipants, - }); - }, [destinationParticipants, to?.name]); + setDestinationPartyId( + destinationPartyOptions.length === 1 ? destinationPartyOptions[0].id : "", + ); + }, [destinationPartyId, destinationPartyOptions]); useEffect(() => { if (!selectedDestinationParty) { setDestinationParticipantId(""); + setDismissedRecommendationIds([]); return; } + setDismissedRecommendationIds([]); setDestinationParticipantId((currentValue) => { if ( - destinationParticipants.some( + selectedDestinationParty.otherParticipants.some( (participant) => participant.id === currentValue, ) ) { return currentValue; } - return participantMatch.exactMatchParticipantId ?? ""; + return ( + selectedDestinationParty.participantMatch.exactMatchParticipantId ?? "" + ); }); - }, [destinationParticipants, participantMatch, selectedDestinationParty]); + }, [selectedDestinationParty]); + + useEffect(() => { + if (step !== "configure" && !canTransfer) { + setStep("configure"); + } + }, [canTransfer, step]); + + useEffect(() => { + if (step !== "success" || !successExpenseId) { + return; + } + + const timeoutId = window.setTimeout(() => { + void navigate({ + to: "/party/$partyId/expense/$expenseId", + params: { + partyId: party.id, + expenseId: successExpenseId, + }, + replace: true, + }); + }, 1250); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [navigate, party.id, step, successExpenseId]); if (!from || !to) { return ( @@ -145,209 +249,370 @@ function RouteComponent() { ); } - const destinationCurrentParticipant = selectedDestinationParty - ? selectedDestinationParty.entry.party.participants[ - selectedDestinationParty.entry.currentParticipantId - ] - : null; const sourceCreditorName = to.name; const originPartyName = party.name; const destinationPartyName = selectedDestinationParty?.entry.party.name ?? ""; const destinationCurrentParticipantName = - destinationCurrentParticipant?.name ?? ""; - const recommendedParticipants = participantMatch.recommendedParticipantIds - .map((participantId) => - destinationParticipants.find( - (participant) => participant.id === participantId, - ), - ) - .filter( - (participant): participant is (typeof destinationParticipants)[number] => - !!participant, - ); - const selectedDestinationCounterparty = destinationParticipants.find( - (participant) => participant.id === destinationParticipantId, - ); + selectedDestinationParty?.currentParticipant.name ?? ""; const selectedDestinationCounterpartyName = selectedDestinationCounterparty?.name ?? ""; - const canTransfer = - !!selectedDestinationParty && - !!selectedDestinationCounterparty && - destinationParticipantId !== ""; + const selectedExactMatchParticipantName = + selectedDestinationParty?.exactMatchParticipant?.name ?? ""; + const pageTitle = + step === "confirm" + ? t`Confirm transfer` + : step === "success" + ? t`Debt transferred` + : t`Transfer debt`; + + function handleRecommendationPress(participantId: string) { + setDestinationParticipantId(participantId); + setDismissedRecommendationIds((currentValue) => + currentValue.includes(participantId) + ? currentValue + : [...currentValue, participantId], + ); + } - function onTransferDebt() { + async function onConfirmTransfer() { if (!selectedDestinationParty || !selectedDestinationCounterparty) { return; } - const transferPromise = transferDebtToParty({ - destinationPartyId: selectedDestinationParty.entry.party.id, - originDebtorId: fromId, - originCreditorId: toId, - destinationDebtorId: selectedDestinationParty.entry.currentParticipantId, - destinationCreditorId: selectedDestinationCounterparty.id, - amount, - paidAt: new Date(), - originExpenseName: t`Debt transfer to another party`, - destinationExpenseName: t`Debt transfer from another party`, - }); - - toast.promise(transferPromise, { - loading: t`Transferring debt...`, - success: t`Debt transferred`, - error: t`Failed to transfer debt`, - }); + try { + setIsSubmitting(true); - void transferPromise.then(({ originExpense }) => { - return navigate({ - to: "/party/$partyId/expense/$expenseId", - params: { - partyId: party.id, - expenseId: originExpense.id, - }, - replace: true, + const { originExpense } = await transferDebtToParty({ + destinationPartyId: selectedDestinationParty.entry.party.id, + originDebtorId: fromId, + originCreditorId: toId, + destinationDebtorId: selectedDestinationParty.currentParticipant.id, + destinationCreditorId: selectedDestinationCounterparty.id, + amount, + paidAt: new Date(), + originExpenseName: t`Debt transfer to another party`, + destinationExpenseName: t`Debt transfer from another party`, }); - }); + + setSuccessExpenseId(originExpense.id); + setStep("success"); + } catch { + setIsSubmitting(false); + toast.error(t`Failed to transfer debt`); + } } return ( - -
+ +
+
- {eligibleDestinationParties.length === 0 ? ( - - ) : ( - <> - - label={t`Destination party`} - description={t`Choose the party where this debt should continue`} - items={destinationPartyOptions} - selectedKey={destinationPartyId || undefined} - onSelectionChange={(value) => { - setDestinationPartyId(String(value ?? "")); - }} - > - {(option) => ( - - {option.entry.party.name} - - )} - +
+ +
- {selectedDestinationParty ? ( -
-
- You are -
-
- {destinationCurrentParticipantName} -
-
- ) : null} + + {step === "success" ? ( + + + + ) : step === "confirm" ? ( + +
+ - {selectedDestinationParty ? ( - - label={t`Who is ${sourceCreditorName} in this party?`} - description={t`Choose the participant who should receive the transferred debt`} - items={destinationParticipants} - selectedKey={destinationParticipantId || undefined} - onSelectionChange={(value) => { - setDestinationParticipantId(String(value ?? "")); - }} - > - {(participant) => ( - - {participant.name} - - )} - - ) : null} + - {selectedDestinationParty && recommendedParticipants.length > 0 ? ( -
-
- Recommendations +
+
+ + + Expenses that will be created +
-
- {recommendedParticipants.map((participant) => ( - - ))} +
+ +
- ) : null} - - {selectedDestinationParty && - destinationParticipants.length === 0 ? ( - - ) : null} - {selectedDestinationParty && selectedDestinationCounterparty ? ( -
-
- - - What will happen - -
+
+ -

- - This will settle the debt in{" "} - {originPartyName} and - create the same debt in{" "} - {destinationPartyName}, - where{" "} - - {selectedDestinationCounterpartyName} - {" "} - is owed by{" "} - - {destinationCurrentParticipantName} - - . - -

+
- ) : null} +
+ + ) : ( + +
+ {destinationPartyOptions.length === 0 ? ( + + ) : ( + <> + - - +
+ {destinationPartyOptions.map((option) => ( + { + setDestinationPartyId(option.id); + }} + /> + ))} +
+ + {selectedDestinationParty ? ( + <> + + + {selectedDestinationParty.exactMatchParticipant ? ( + + + + + Exact name match selected automatically:{" "} + {selectedExactMatchParticipantName} + + + + ) : null} + + {displayedRecommendedParticipants.length > 0 ? ( +
+
+ Quick recommendations +
+ +
+ + {displayedRecommendedParticipants.map( + (participant) => ( + { + handleRecommendationPress(participant.id); + }} + > + + {participant.name} + + ), + )} + +
+
+ ) : null} + + {destinationParticipants.length === 0 ? ( + + ) : ( +
+ {destinationParticipants.map((participant) => ( + candidate.id === participant.id, + )} + isExactMatch={ + selectedDestinationParty.exactMatchParticipant + ?.id === participant.id + } + isSelected={ + participant.id === destinationParticipantId + } + participant={participant} + onPress={() => { + setDestinationParticipantId(participant.id); + }} + /> + ))} +
+ )} + + {selectedDestinationCounterparty ? ( +
+
+ + + Preview + +
+ +

+ + This will settle the debt in{" "} + + {originPartyName} + {" "} + and recreate it in{" "} + + {destinationPartyName} + + , where{" "} + + {selectedDestinationCounterpartyName} + {" "} + will be owed by{" "} + + {destinationCurrentParticipantName} + + . + +

+
+ ) : null} + + + + ) : null} + + )} +
+
)} -
+ -
+ {step === "success" ? null :
} ); } @@ -355,14 +620,20 @@ function RouteComponent() { function TransferDebtLayout({ title, children, + showBackButton = true, }: { title: string; children: React.ReactNode; + showBackButton?: boolean; }) { return (
- + {showBackButton ? ( + + ) : ( +
+ )}

{title}

@@ -371,6 +642,66 @@ function TransferDebtLayout({ ); } +function TransferStepIndicator({ step }: { step: TransferStep }) { + return ( +
+ + / + + / + +
+ ); +} + +function StepDot({ isActive, label }: { isActive: boolean; label: string }) { + return ( + + + {label} + + ); +} + +function SectionIntro({ + eyebrow, + title, + description, +}: { + eyebrow: string; + title: string; + description: string; +}) { + return ( +
+
+ {eyebrow} +
+

{title}

+

+ {description} +

+
+ ); +} + function DebtSummaryCard({ fromName, toName, @@ -383,22 +714,460 @@ function DebtSummaryCard({ currency: Currency; }) { return ( -
+
- {fromName} - + + {fromName} + + owes - {toName} + + {toName} +
- + +
+
+ ); +} + +function DestinationPartyCard({ + option, + isSelected, + sourceCreditorName, + onPress, +}: { + option: DestinationPartyOption; + isSelected: boolean; + sourceCreditorName: string; + onPress: () => void; +}) { + const topRecommendation = option.recommendedParticipants[0] ?? null; + const currentParticipantName = option.currentParticipant.name; + const exactMatchParticipantName = option.exactMatchParticipant?.name ?? ""; + const topRecommendationName = topRecommendation?.name ?? ""; + + return ( + + ); +} + +function DestinationParticipantCard({ + participant, + isSelected, + isRecommended, + isExactMatch, + onPress, +}: { + participant: PartyParticipant; + isSelected: boolean; + isRecommended: boolean; + isExactMatch: boolean; + onPress: () => void; +}) { + return ( + + ); +} + +function TransferReviewCard({ + amount, + currency, + fromName, + toName, + originParty, + destinationParty, + destinationDebtorName, + destinationCreditorName, +}: { + amount: number; + currency: Currency; + fromName: string; + toName: string; + originParty: Party; + destinationParty?: Party; + destinationDebtorName: string; + destinationCreditorName: string; +}) { + return ( +
+
+
+
+ Debt being moved +
+
+ {fromName} {toName} +
+
+ + +
+ +
+ + {fromName} stops owing {toName} + + } + /> + + {destinationParty ? ( + + {destinationCreditorName} is owed by {destinationDebtorName} + + } + /> + ) : null} +
+
+ ); +} + +function ReviewPartyRow({ + caption, + party, + detail, +}: { + caption: string; + party: Party; + detail: React.ReactNode; +}) { + return ( +
+ + +
+
+ {caption} +
+
+ {party.name} +
+
+ {detail} +
+
+
+ ); +} + +function ExpensePreviewCard({ + label, + partyName, + expenseName, + detail, +}: { + label: string; + partyName: string; + expenseName: string; + detail: string; +}) { + return ( +
+
+ {label} +
+
+ {partyName} +
+
+ {expenseName} +
+
+ {detail} +
+
+ ); +} + +function TransferSuccessState({ + destinationPartyName, + destinationCounterpartyName, + destinationDebtorName, +}: { + destinationPartyName: string; + destinationCounterpartyName: string; + destinationDebtorName: string; +}) { + return ( +
+
+ + + + + + + +
+ +

+ Debt transferred +

+ +

+ + The new debt is now in{" "} + {destinationPartyName}, where{" "} + {destinationCounterpartyName}{" "} + is owed by{" "} + {destinationDebtorName}. + +

+ +
+ + Opening updated expense…
); } +function TransferParticipantAvatar({ + participant, + className, +}: { + participant: PartyParticipant; + className?: string; +}) { + if (!participant.avatarId) { + return ; + } + + return ( + } + > + + + ); +} + +function TransferParticipantAvatarImage({ + avatarId, + name, + className, +}: { + avatarId: NonNullable; + name: string; + className?: string; +}) { + const { url } = useMediaFile(avatarId); + + return ; +} + +function PartySymbolBadge({ + party, + className, +}: { + party: Party; + className?: string; +}) { + const symbol = party.symbol || party.name.charAt(0).toUpperCase(); + + return ( +
+ {symbol} +
+ ); +} + +function InfoPill({ + children, + tone = "default", +}: { + children: React.ReactNode; + tone?: "accent" | "default"; +}) { + return ( + + {children} + + ); +} + function InlineAlert({ title, description, From ebba69c5076361504b1a59da02adde7e87130ffd Mon Sep 17 00:00:00 2001 From: Horus Lugo Date: Mon, 20 Apr 2026 19:54:19 +0200 Subject: [PATCH 5/5] Simplify debt transfer flow - Split transfer into party, participant, and confirm steps - Update E2E coverage and localized copy for the new flow - Add changelog entry --- .changeset/silver-steps-rest.md | 5 + packages/pwa/e2e/debt-transfer.spec.ts | 10 +- packages/pwa/e2e/pages/transfer-debt.page.ts | 50 +- packages/pwa/locale/en/messages.po | 130 +++-- packages/pwa/locale/es/messages.po | 130 +++-- .../routes/party_.$partyId.transfer-debt.tsx | 491 +++++++----------- 6 files changed, 392 insertions(+), 424 deletions(-) create mode 100644 .changeset/silver-steps-rest.md diff --git a/.changeset/silver-steps-rest.md b/.changeset/silver-steps-rest.md new file mode 100644 index 00000000..aa9779c6 --- /dev/null +++ b/.changeset/silver-steps-rest.md @@ -0,0 +1,5 @@ +--- +"@trizum/pwa": patch +--- + +Simplify the debt transfer flow by separating the party, creditor, and confirmation screens and removing repeated transfer details. diff --git a/packages/pwa/e2e/debt-transfer.spec.ts b/packages/pwa/e2e/debt-transfer.spec.ts index 4859a015..051401a4 100644 --- a/packages/pwa/e2e/debt-transfer.spec.ts +++ b/packages/pwa/e2e/debt-transfer.spec.ts @@ -71,7 +71,7 @@ test.describe("Debt transfer", () => { ); await test.step( - "pick the destination party and use the recommended creditor match", + "continue in the eligible destination party and choose the creditor", async () => { await originPartyPage.openSettlementActionButton( originAction, @@ -84,13 +84,11 @@ test.describe("Debt transfer", () => { fromId: defaultParticipants.blair.id, toId: defaultParticipants.alex.id, }); - await transferDebtPage.chooseDestinationParty( - debtTransferJourney.destinationPartyName, - ); - await transferDebtPage.expectRecommendation( + await transferDebtPage.expectParticipantStep(); + await transferDebtPage.expectRecommendedParticipant( debtTransferJourney.destinationCreditorParticipant.name, ); - await transferDebtPage.chooseRecommendation( + await transferDebtPage.chooseParticipant( debtTransferJourney.destinationCreditorParticipant.name, ); }, diff --git a/packages/pwa/e2e/pages/transfer-debt.page.ts b/packages/pwa/e2e/pages/transfer-debt.page.ts index 2ad2e8f0..bb4ca04d 100644 --- a/packages/pwa/e2e/pages/transfer-debt.page.ts +++ b/packages/pwa/e2e/pages/transfer-debt.page.ts @@ -2,27 +2,23 @@ import { expect, type Locator, type Page } from "@playwright/test"; export class TransferDebtPage { readonly page: Page; - readonly selectionStep: Locator; + readonly partyStep: Locator; + readonly participantStep: Locator; readonly confirmationStep: Locator; - readonly reviewTransferButton: Locator; + readonly continueButton: Locator; readonly confirmTransferButton: Locator; - readonly recommendationsSection: Locator; constructor(page: Page) { this.page = page; - this.selectionStep = page.getByTestId("transfer-debt-selection-step"); + this.partyStep = page.getByTestId("transfer-debt-party-step"); + this.participantStep = page.getByTestId("transfer-debt-participant-step"); this.confirmationStep = page.getByTestId("transfer-debt-confirmation-step"); - this.reviewTransferButton = page.getByRole("button", { - name: "Review transfer", + this.continueButton = page.getByRole("button", { + name: "Continue", }); this.confirmTransferButton = page.getByRole("button", { name: "Confirm transfer", }); - this.recommendationsSection = this.selectionStep - .locator("div.rounded-3xl") - .filter({ - has: page.getByText("Quick recommendations", { exact: true }), - }); } async expectLoaded() { @@ -30,10 +26,6 @@ export class TransferDebtPage { await expect( this.page.getByRole("heading", { exact: true, name: "Transfer debt" }), ).toBeVisible(); - await expect(this.selectionStep).toBeVisible(); - await expect( - this.page.getByText("Choose where the debt should continue"), - ).toBeVisible(); } async expectSearchParams(params: { @@ -54,24 +46,27 @@ export class TransferDebtPage { } async chooseDestinationParty(partyName: string) { - await this.selectionStep + await this.partyStep .locator("button") .filter({ hasText: partyName }) .first() .click(); + await expect(this.participantStep).toBeVisible(); + } + + async expectParticipantStep() { + await expect(this.participantStep).toBeVisible(); + await expect(this.page.getByText("Choose who receives it")).toBeVisible(); } - async expectRecommendation(participantName: string) { + async expectRecommendedParticipant(participantName: string) { await expect( - this.recommendationsSection - .locator("button") - .filter({ hasText: participantName }) - .first(), - ).toBeVisible(); + this.participantStep.locator("button").filter({ hasText: participantName }), + ).toContainText("Recommended"); } - async chooseRecommendation(participantName: string) { - await this.recommendationsSection + async chooseParticipant(participantName: string) { + await this.participantStep .locator("button") .filter({ hasText: participantName }) .first() @@ -79,9 +74,12 @@ export class TransferDebtPage { } async completeTransfer() { - await expect(this.reviewTransferButton).toBeVisible(); - await this.reviewTransferButton.click(); + await expect(this.continueButton).toBeVisible(); + await this.continueButton.click(); await expect(this.confirmationStep).toBeVisible(); + await expect( + this.page.getByText("This will settle the debt", { exact: false }), + ).toBeVisible(); await this.confirmTransferButton.click(); } } diff --git a/packages/pwa/locale/en/messages.po b/packages/pwa/locale/en/messages.po index 4e191731..07f50b0a 100644 --- a/packages/pwa/locale/en/messages.po +++ b/packages/pwa/locale/en/messages.po @@ -24,7 +24,6 @@ msgstr "(me)" msgid "[DEV] Create expenses" msgstr "[DEV] Create expenses" -#. placeholder {0}: option.otherParticipants.length #: src/routes/party_.$partyId.transfer-debt.tsx:802 msgid "{0, plural, one {# possible creditor} other {# possible creditors}}" msgstr "{0, plural, one {# possible creditor} other {# possible creditors}}" @@ -34,11 +33,11 @@ msgstr "{0, plural, one {# possible creditor} other {# possible creditors}}" msgid "{0, plural, one {and # other} other {and # others}}" msgstr "{0, plural, one {and # other} other {and # others}}" -#: src/routes/party_.$partyId.transfer-debt.tsx:967 +#: src/routes/party_.$partyId.transfer-debt.tsx:855 msgid "{destinationCreditorName} is owed by {destinationDebtorName}" msgstr "{destinationCreditorName} is owed by {destinationDebtorName}" -#: src/routes/party_.$partyId.transfer-debt.tsx:956 +#: src/routes/party_.$partyId.transfer-debt.tsx:844 msgid "{fromName} stops owing {toName}" msgstr "{fromName} stops owing {toName}" @@ -308,10 +307,14 @@ msgstr "Belize Dollar" msgid "Bermudan Dollar" msgstr "Bermudan Dollar" -#: src/routes/party_.$partyId.transfer-debt.tsx:813 +#: src/routes/party_.$partyId.transfer-debt.tsx:710 msgid "Best match for {sourceCreditorName}: <0>{exactMatchParticipantName}" msgstr "Best match for {sourceCreditorName}: <0>{exactMatchParticipantName}" +#: src/routes/party_.$partyId.transfer-debt.tsx:717 +msgid "Best match for {sourceCreditorName}: <0>{topRecommendationName}" +msgstr "Best match for {sourceCreditorName}: <0>{topRecommendationName}" + #: src/routes/new.tsx:473 msgid "Bhutanese Ngultrum" msgstr "Bhutanese Ngultrum" @@ -452,6 +455,18 @@ msgstr "Chinese Yuan" msgid "Choose" msgstr "Choose" +#: src/routes/party_.$partyId.transfer-debt.tsx:494 +msgid "Choose a destination party" +msgstr "Choose a destination party" + +#: src/routes/party_.$partyId.transfer-debt.tsx:578 +msgid "Choose a party" +msgstr "Choose a party" + +#: src/routes/party_.$partyId.transfer-debt.tsx:580 +msgid "Choose a person" +msgstr "Choose a person" + #: src/routes/new.tsx:255 msgid "Choose the currency for expenses in this party" msgstr "Choose the currency for expenses in this party" @@ -464,14 +479,26 @@ msgstr "Choose the participant who should receive the transferred debt" msgid "Choose the party where this debt should continue" msgstr "Choose the party where this debt should continue" +#: src/routes/party_.$partyId.transfer-debt.tsx:722 +msgid "Choose the person on the next step" +msgstr "Choose the person on the next step" + #: src/routes/party_.$partyId.transfer-debt.tsx:449 msgid "Choose where the debt should continue" msgstr "Choose where the debt should continue" +#: src/routes/party_.$partyId.transfer-debt.tsx:427 +msgid "Choose who receives it" +msgstr "Choose who receives it" + #: src/routes/party_.$partyId.transfer-debt.tsx:471 msgid "Choose who receives the debt there" msgstr "Choose who receives the debt there" +#: src/routes/party_.$partyId.transfer-debt.tsx:275 +msgid "Choose who should be owed after the transfer." +msgstr "Choose who should be owed after the transfer." + #: src/components/CalculatorToolbar.tsx:449 msgid "Clear all" msgstr "Clear all" @@ -529,16 +556,21 @@ msgstr "Complete your profile" msgid "Configure Profile" msgstr "Configure Profile" +#: src/routes/party_.$partyId.transfer-debt.tsx:581 +msgid "Confirm" +msgstr "Confirm" + #: src/routes/party_.$partyId.transfer-debt.tsx:348 msgid "Confirm this transfer" msgstr "Confirm this transfer" -#: src/routes/party_.$partyId.transfer-debt.tsx:263 -#: src/routes/party_.$partyId.transfer-debt.tsx:422 +#: src/routes/party_.$partyId.transfer-debt.tsx:269 +#: src/routes/party_.$partyId.transfer-debt.tsx:367 +#: src/routes/party_.$partyId.transfer-debt.tsx:407 msgid "Confirm transfer" msgstr "Confirm transfer" -#: src/routes/party_.$partyId.transfer-debt.tsx:415 +#: src/routes/party_.$partyId.transfer-debt.tsx:400 msgid "Confirming..." msgstr "Confirming..." @@ -550,6 +582,10 @@ msgstr "Congolese Franc" msgid "Contact Us" msgstr "Contact Us" +#: src/routes/party_.$partyId.transfer-debt.tsx:470 +msgid "Continue" +msgstr "Continue" + #: src/routes/about.tsx:130 msgid "Contributors" msgstr "Contributors" @@ -579,6 +615,10 @@ msgstr "Created with ❤️ by the open source community. Special thanks to all msgid "Creates the same debt in the selected party" msgstr "Creates the same debt in the selected party" +#: src/routes/party_.$partyId.transfer-debt.tsx:426 +msgid "Creditor" +msgstr "Creditor" + #: src/routes/about.tsx:141 msgid "Credits" msgstr "Credits" @@ -626,7 +666,7 @@ msgstr "Danish Krone" msgid "Date" msgstr "Date" -#: src/routes/party_.$partyId.transfer-debt.tsx:937 +#: src/routes/party_.$partyId.transfer-debt.tsx:825 msgid "Debt being moved" msgstr "Debt being moved" @@ -638,18 +678,16 @@ msgstr "Debt settled between {0} and {1}!" msgid "Debt settled between {fromName} and {toName}!" msgstr "Debt settled between {fromName} and {toName}!" -#: src/routes/party_.$partyId.transfer-debt.tsx:294 -#: src/routes/party_.$partyId.transfer-debt.tsx:381 +#: src/routes/party_.$partyId.transfer-debt.tsx:305 msgid "Debt transfer from another party" msgstr "Debt transfer from another party" -#: src/routes/party_.$partyId.transfer-debt.tsx:293 -#: src/routes/party_.$partyId.transfer-debt.tsx:375 +#: src/routes/party_.$partyId.transfer-debt.tsx:304 msgid "Debt transfer to another party" msgstr "Debt transfer to another party" -#: src/routes/party_.$partyId.transfer-debt.tsx:265 -#: src/routes/party_.$partyId.transfer-debt.tsx:1070 +#: src/routes/party_.$partyId.transfer-debt.tsx:271 +#: src/routes/party_.$partyId.transfer-debt.tsx:929 msgid "Debt transferred" msgstr "Debt transferred" @@ -670,7 +708,7 @@ msgstr "Description" msgid "Description must be less than 500 characters" msgstr "Description must be less than 500 characters" -#: src/routes/party_.$partyId.transfer-debt.tsx:379 +#: src/routes/party_.$partyId.transfer-debt.tsx:493 msgid "Destination party" msgstr "Destination party" @@ -784,7 +822,7 @@ msgstr "Everything is archived for now. You can reopen any party from the archiv msgid "Exact" msgstr "Exact" -#: src/routes/party_.$partyId.transfer-debt.tsx:876 +#: src/routes/party_.$partyId.transfer-debt.tsx:773 msgid "Exact match" msgstr "Exact match" @@ -852,7 +890,7 @@ msgstr "Failed to load licenses. Please try again later." msgid "Failed to mark expense as paid" msgstr "Failed to mark expense as paid" -#: src/routes/party_.$partyId.transfer-debt.tsx:301 +#: src/routes/party_.$partyId.transfer-debt.tsx:312 msgid "Failed to transfer debt" msgstr "Failed to transfer debt" @@ -925,10 +963,11 @@ msgid "Go back" msgstr "Go back" #: src/components/BackButton.tsx:20 +#: src/routes/party_.$partyId.transfer-debt.tsx:542 msgid "Go Back" msgstr "Go Back" -#: src/routes/party_.$partyId.transfer-debt.tsx:235 +#: src/routes/party_.$partyId.transfer-debt.tsx:243 msgid "Go back and try again from the balances list." msgstr "Go back and try again from the balances list." @@ -1049,6 +1088,10 @@ msgstr "Importing attachments ({0} of {1})" msgid "Importing Tricount data..." msgstr "Importing Tricount data..." +#: src/routes/party_.$partyId.transfer-debt.tsx:274 +msgid "In {destinationPartyName}, {destinationCurrentParticipantName} will owe the selected person." +msgstr "In {destinationPartyName}, {destinationCurrentParticipantName} will owe the selected person." + #: src/components/ExpenseEditor.tsx:469 msgid "Include all" msgstr "Include all" @@ -1303,7 +1346,7 @@ msgid "Migration successful" msgstr "Migration successful" #: src/routes/party_.$partyId.pay.tsx:24 -#: src/routes/party_.$partyId.transfer-debt.tsx:58 +#: src/routes/party_.$partyId.transfer-debt.tsx:59 msgid "Missing search params" msgstr "Missing search params" @@ -1327,6 +1370,10 @@ msgstr "Move cursor left" msgid "Move cursor right" msgstr "Move cursor right" +#: src/routes/party_.$partyId.transfer-debt.tsx:495 +msgid "Move this debt into one of your other active parties." +msgstr "Move this debt into one of your other active parties." + #: src/routes/new.tsx:538 msgid "Mozambican Metical" msgstr "Mozambican Metical" @@ -1418,7 +1465,7 @@ msgstr "No camera found on this device" msgid "No currency" msgstr "No currency" -#: src/routes/party_.$partyId.transfer-debt.tsx:442 +#: src/routes/party_.$partyId.transfer-debt.tsx:487 msgid "No destination party available" msgstr "No destination party available" @@ -1447,7 +1494,7 @@ msgstr "No spending stats yet" msgid "No updates available" msgstr "No updates available" -#: src/routes/party_.$partyId.transfer-debt.tsx:525 +#: src/routes/party_.$partyId.transfer-debt.tsx:433 msgid "Nobody else is available in this party" msgstr "Nobody else is available in this party" @@ -1495,7 +1542,7 @@ msgstr "Omani Rial" msgid "Only show your expenses" msgstr "Only show your expenses" -#: src/routes/party_.$partyId.transfer-debt.tsx:245 +#: src/routes/party_.$partyId.transfer-debt.tsx:253 msgid "Only your own debt can be transferred" msgstr "Only your own debt can be transferred" @@ -1523,7 +1570,7 @@ msgstr "Open last party on launch" msgid "Open parenthesis" msgstr "Open parenthesis" -#: src/routes/party_.$partyId.transfer-debt.tsx:1085 +#: src/routes/party_.$partyId.transfer-debt.tsx:944 msgid "Opening updated expense…" msgstr "Opening updated expense…" @@ -1544,7 +1591,7 @@ msgid "Other operations" msgstr "Other operations" #: src/routes/party_.$partyId.pay.tsx:104 -#: src/routes/party_.$partyId.transfer-debt.tsx:723 +#: src/routes/party_.$partyId.transfer-debt.tsx:636 #: src/routes/party.$partyId.tsx:986 msgid "owes" msgstr "owes" @@ -1803,11 +1850,11 @@ msgstr "Receipt Attachments" msgid "Recommendations" msgstr "Recommendations" -#: src/routes/party_.$partyId.transfer-debt.tsx:885 +#: src/routes/party_.$partyId.transfer-debt.tsx:782 msgid "Recommended" msgstr "Recommended" -#: src/routes/party_.$partyId.transfer-debt.tsx:964 +#: src/routes/party_.$partyId.transfer-debt.tsx:852 msgid "Recreated in" msgstr "Recreated in" @@ -1848,8 +1895,7 @@ msgstr "Restore to home" msgid "Retry" msgstr "Retry" -#: src/routes/party_.$partyId.transfer-debt.tsx:347 -#: src/routes/party_.$partyId.transfer-debt.tsx:652 +#: src/routes/party_.$partyId.transfer-debt.tsx:366 msgid "Review" msgstr "Review" @@ -1945,7 +1991,7 @@ msgstr "Settings" msgid "Settings saved" msgstr "Settings saved" -#: src/routes/party_.$partyId.transfer-debt.tsx:953 +#: src/routes/party_.$partyId.transfer-debt.tsx:841 msgid "Settled in" msgstr "Settled in" @@ -2064,6 +2110,10 @@ msgstr "Start tracking expenses with your group" msgid "Stats" msgstr "Stats" +#: src/routes/party_.$partyId.transfer-debt.tsx:586 +msgid "Step {currentStep} of {totalSteps}" +msgstr "Step {currentStep} of {totalSteps}" + #: src/routes/party_.$partyId.transfer-debt.tsx:448 msgid "Step 1" msgstr "Step 1" @@ -2184,7 +2234,7 @@ msgstr "Thank you for using trizum. Your feedback helps us improve!" msgid "The app works offline too!" msgstr "The app works offline too!" -#: src/routes/party_.$partyId.transfer-debt.tsx:1074 +#: src/routes/party_.$partyId.transfer-debt.tsx:933 msgid "The new debt is now in <0>{destinationPartyName}, where <1>{destinationCounterpartyName} is owed by <2>{destinationDebtorName}." msgstr "The new debt is now in <0>{destinationPartyName}, where <1>{destinationCounterpartyName} is owed by <2>{destinationDebtorName}." @@ -2210,7 +2260,7 @@ msgstr "Third-Party Licenses" msgid "This application uses the following open source libraries and tools. Below are their licenses and attributions." msgstr "This application uses the following open source libraries and tools. Below are their licenses and attributions." -#: src/routes/party_.$partyId.transfer-debt.tsx:234 +#: src/routes/party_.$partyId.transfer-debt.tsx:242 msgid "This debt is no longer available" msgstr "This debt is no longer available" @@ -2222,7 +2272,7 @@ msgstr "This HEIC or HEIF image could not be processed. Try another photo or exp msgid "This isn't a valid ID" msgstr "This isn't a valid ID" -#: src/routes/party_.$partyId.transfer-debt.tsx:526 +#: src/routes/party_.$partyId.transfer-debt.tsx:434 msgid "This party needs another active participant besides you to receive the transferred debt." msgstr "This party needs another active participant besides you to receive the transferred debt." @@ -2230,6 +2280,10 @@ msgstr "This party needs another active participant besides you to receive the t msgid "This project uses various open source libraries and tools." msgstr "This project uses various open source libraries and tools." +#: src/routes/party_.$partyId.transfer-debt.tsx:276 +msgid "This will settle the debt in {originPartyName} and recreate it in {destinationPartyName}." +msgstr "This will settle the debt in {originPartyName} and recreate it in {destinationPartyName}." + #: src/routes/party_.$partyId.transfer-debt.tsx:296 msgid "This will settle the debt in <0>{0} and create the same debt in <1>{1}, where <2>{2} is owed by <3>{3}." msgstr "This will settle the debt in <0>{0} and create the same debt in <1>{1}, where <2>{2} is owed by <3>{3}." @@ -2287,9 +2341,9 @@ msgstr "Total spent" msgid "Total split:" msgstr "Total split:" -#: src/routes/party_.$partyId.transfer-debt.tsx:232 -#: src/routes/party_.$partyId.transfer-debt.tsx:243 -#: src/routes/party_.$partyId.transfer-debt.tsx:266 +#: src/routes/party_.$partyId.transfer-debt.tsx:240 +#: src/routes/party_.$partyId.transfer-debt.tsx:251 +#: src/routes/party_.$partyId.transfer-debt.tsx:272 #: src/routes/party.$partyId.tsx:1041 msgid "Transfer debt" msgstr "Transfer debt" @@ -2556,7 +2610,7 @@ msgstr "Yes! Your data is stored locally on your device and only synced with peo msgid "You are" msgstr "You are" -#: src/routes/party_.$partyId.transfer-debt.tsx:795 +#: src/routes/party_.$partyId.transfer-debt.tsx:705 msgid "You are {currentParticipantName}" msgstr "You are {currentParticipantName}" @@ -2564,7 +2618,7 @@ msgstr "You are {currentParticipantName}" msgid "You can add photos to your expenses for better tracking." msgstr "You can add photos to your expenses for better tracking." -#: src/routes/party_.$partyId.transfer-debt.tsx:246 +#: src/routes/party_.$partyId.transfer-debt.tsx:254 msgid "You can only transfer debt from actions where you are the one who owes the money." msgstr "You can only transfer debt from actions where you are the one who owes the money." @@ -2572,7 +2626,7 @@ msgstr "You can only transfer debt from actions where you are the one who owes t msgid "You left the party!" msgstr "You left the party!" -#: src/routes/party_.$partyId.transfer-debt.tsx:443 +#: src/routes/party_.$partyId.transfer-debt.tsx:488 msgid "You need another active party with the same currency to transfer this debt." msgstr "You need another active party with the same currency to transfer this debt." diff --git a/packages/pwa/locale/es/messages.po b/packages/pwa/locale/es/messages.po index eeb289a2..74e9f1a9 100644 --- a/packages/pwa/locale/es/messages.po +++ b/packages/pwa/locale/es/messages.po @@ -24,7 +24,6 @@ msgstr "(yo)" msgid "[DEV] Create expenses" msgstr "[DEV] Crear gastos" -#. placeholder {0}: option.otherParticipants.length #: src/routes/party_.$partyId.transfer-debt.tsx:802 msgid "{0, plural, one {# possible creditor} other {# possible creditors}}" msgstr "" @@ -34,11 +33,11 @@ msgstr "" msgid "{0, plural, one {and # other} other {and # others}}" msgstr "{0, plural, one {y # más} other {y # más}}" -#: src/routes/party_.$partyId.transfer-debt.tsx:967 +#: src/routes/party_.$partyId.transfer-debt.tsx:855 msgid "{destinationCreditorName} is owed by {destinationDebtorName}" msgstr "" -#: src/routes/party_.$partyId.transfer-debt.tsx:956 +#: src/routes/party_.$partyId.transfer-debt.tsx:844 msgid "{fromName} stops owing {toName}" msgstr "" @@ -296,10 +295,14 @@ msgstr "Dólar Beliceño" msgid "Bermudan Dollar" msgstr "Dólar Bermudeño" -#: src/routes/party_.$partyId.transfer-debt.tsx:813 +#: src/routes/party_.$partyId.transfer-debt.tsx:710 msgid "Best match for {sourceCreditorName}: <0>{exactMatchParticipantName}" msgstr "" +#: src/routes/party_.$partyId.transfer-debt.tsx:717 +msgid "Best match for {sourceCreditorName}: <0>{topRecommendationName}" +msgstr "" + #: src/routes/new.tsx:473 msgid "Bhutanese Ngultrum" msgstr "Ngultrum Butanés" @@ -428,6 +431,18 @@ msgstr "Yuan Chino" msgid "Choose" msgstr "" +#: src/routes/party_.$partyId.transfer-debt.tsx:494 +msgid "Choose a destination party" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:578 +msgid "Choose a party" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:580 +msgid "Choose a person" +msgstr "" + #: src/routes/new.tsx:255 msgid "Choose the currency for expenses in this party" msgstr "Elige la moneda para los gastos de este grupo" @@ -440,14 +455,26 @@ msgstr "" msgid "Choose the party where this debt should continue" msgstr "" +#: src/routes/party_.$partyId.transfer-debt.tsx:722 +msgid "Choose the person on the next step" +msgstr "" + #: src/routes/party_.$partyId.transfer-debt.tsx:449 msgid "Choose where the debt should continue" msgstr "" +#: src/routes/party_.$partyId.transfer-debt.tsx:427 +msgid "Choose who receives it" +msgstr "" + #: src/routes/party_.$partyId.transfer-debt.tsx:471 msgid "Choose who receives the debt there" msgstr "" +#: src/routes/party_.$partyId.transfer-debt.tsx:275 +msgid "Choose who should be owed after the transfer." +msgstr "" + #: src/components/CalculatorToolbar.tsx:449 msgid "Clear all" msgstr "Borrar todo" @@ -505,16 +532,21 @@ msgstr "Completa tu perfil" msgid "Configure Profile" msgstr "Configurar perfil" +#: src/routes/party_.$partyId.transfer-debt.tsx:581 +msgid "Confirm" +msgstr "" + #: src/routes/party_.$partyId.transfer-debt.tsx:348 msgid "Confirm this transfer" msgstr "" -#: src/routes/party_.$partyId.transfer-debt.tsx:263 -#: src/routes/party_.$partyId.transfer-debt.tsx:422 +#: src/routes/party_.$partyId.transfer-debt.tsx:269 +#: src/routes/party_.$partyId.transfer-debt.tsx:367 +#: src/routes/party_.$partyId.transfer-debt.tsx:407 msgid "Confirm transfer" msgstr "" -#: src/routes/party_.$partyId.transfer-debt.tsx:415 +#: src/routes/party_.$partyId.transfer-debt.tsx:400 msgid "Confirming..." msgstr "" @@ -526,6 +558,10 @@ msgstr "Franco Congoleño" msgid "Contact Us" msgstr "Contacto" +#: src/routes/party_.$partyId.transfer-debt.tsx:470 +msgid "Continue" +msgstr "" + #: src/routes/about.tsx:130 msgid "Contributors" msgstr "Colaboradores" @@ -551,6 +587,10 @@ msgstr "Crear un nuevo Grupo" msgid "Creates the same debt in the selected party" msgstr "" +#: src/routes/party_.$partyId.transfer-debt.tsx:426 +msgid "Creditor" +msgstr "" + #: src/routes/about.tsx:141 msgid "Credits" msgstr "Créditos" @@ -598,7 +638,7 @@ msgstr "Corona Danesa" msgid "Date" msgstr "Fecha" -#: src/routes/party_.$partyId.transfer-debt.tsx:937 +#: src/routes/party_.$partyId.transfer-debt.tsx:825 msgid "Debt being moved" msgstr "" @@ -610,18 +650,16 @@ msgstr "¡Deuda saldada entre {0} y {1}!" msgid "Debt settled between {fromName} and {toName}!" msgstr "¡Deuda saldada entre {fromName} y {toName}!" -#: src/routes/party_.$partyId.transfer-debt.tsx:294 -#: src/routes/party_.$partyId.transfer-debt.tsx:381 +#: src/routes/party_.$partyId.transfer-debt.tsx:305 msgid "Debt transfer from another party" msgstr "" -#: src/routes/party_.$partyId.transfer-debt.tsx:293 -#: src/routes/party_.$partyId.transfer-debt.tsx:375 +#: src/routes/party_.$partyId.transfer-debt.tsx:304 msgid "Debt transfer to another party" msgstr "" -#: src/routes/party_.$partyId.transfer-debt.tsx:265 -#: src/routes/party_.$partyId.transfer-debt.tsx:1070 +#: src/routes/party_.$partyId.transfer-debt.tsx:271 +#: src/routes/party_.$partyId.transfer-debt.tsx:929 msgid "Debt transferred" msgstr "" @@ -642,7 +680,7 @@ msgstr "Descripción" msgid "Description must be less than 500 characters" msgstr "La descripción debe tener menos de 500 caracteres" -#: src/routes/party_.$partyId.transfer-debt.tsx:379 +#: src/routes/party_.$partyId.transfer-debt.tsx:493 msgid "Destination party" msgstr "" @@ -748,7 +786,7 @@ msgstr "Unidad de Cuenta Europea 9" msgid "Everything is archived for now. You can reopen any party from the archived screen whenever you need it." msgstr "Por ahora todo está archivado. Puedes reabrir cualquier grupo desde la pantalla de archivados cuando lo necesites." -#: src/routes/party_.$partyId.transfer-debt.tsx:876 +#: src/routes/party_.$partyId.transfer-debt.tsx:773 msgid "Exact match" msgstr "" @@ -808,7 +846,7 @@ msgstr "Error al cargar las licencias. Por favor, inténtalo más tarde." msgid "Failed to mark expense as paid" msgstr "Error al marcar el gasto como pagado" -#: src/routes/party_.$partyId.transfer-debt.tsx:301 +#: src/routes/party_.$partyId.transfer-debt.tsx:312 msgid "Failed to transfer debt" msgstr "" @@ -881,10 +919,11 @@ msgid "Go back" msgstr "Volver" #: src/components/BackButton.tsx:20 +#: src/routes/party_.$partyId.transfer-debt.tsx:542 msgid "Go Back" msgstr "Volver" -#: src/routes/party_.$partyId.transfer-debt.tsx:235 +#: src/routes/party_.$partyId.transfer-debt.tsx:243 msgid "Go back and try again from the balances list." msgstr "" @@ -1001,6 +1040,10 @@ msgstr "Importando adjuntos ({0} de {1})" msgid "Importing Tricount data..." msgstr "Importando datos de Tricount..." +#: src/routes/party_.$partyId.transfer-debt.tsx:274 +msgid "In {destinationPartyName}, {destinationCurrentParticipantName} will owe the selected person." +msgstr "" + #: src/components/ExpenseEditor.tsx:469 msgid "Include all" msgstr "Incluir todos" @@ -1251,7 +1294,7 @@ msgid "Migration successful" msgstr "Migración exitosa" #: src/routes/party_.$partyId.pay.tsx:24 -#: src/routes/party_.$partyId.transfer-debt.tsx:58 +#: src/routes/party_.$partyId.transfer-debt.tsx:59 msgid "Missing search params" msgstr "Faltan parámetros de búsqueda" @@ -1275,6 +1318,10 @@ msgstr "Mover cursor a la izquierda" msgid "Move cursor right" msgstr "Mover cursor a la derecha" +#: src/routes/party_.$partyId.transfer-debt.tsx:495 +msgid "Move this debt into one of your other active parties." +msgstr "" + #: src/routes/new.tsx:538 msgid "Mozambican Metical" msgstr "Metical Mozambiqueño" @@ -1366,7 +1413,7 @@ msgstr "No se encontró cámara en este dispositivo" msgid "No currency" msgstr "Sin moneda" -#: src/routes/party_.$partyId.transfer-debt.tsx:442 +#: src/routes/party_.$partyId.transfer-debt.tsx:487 msgid "No destination party available" msgstr "" @@ -1391,7 +1438,7 @@ msgstr "No hay estadísticas de gastos para este período" msgid "No spending stats yet" msgstr "Aún no hay estadísticas de gastos" -#: src/routes/party_.$partyId.transfer-debt.tsx:525 +#: src/routes/party_.$partyId.transfer-debt.tsx:433 msgid "Nobody else is available in this party" msgstr "" @@ -1439,7 +1486,7 @@ msgstr "Rial Omaní" msgid "Only show your expenses" msgstr "Mostrar solo tus gastos" -#: src/routes/party_.$partyId.transfer-debt.tsx:245 +#: src/routes/party_.$partyId.transfer-debt.tsx:253 msgid "Only your own debt can be transferred" msgstr "" @@ -1467,7 +1514,7 @@ msgstr "Abrir el último grupo al abrir la app" msgid "Open parenthesis" msgstr "Abrir paréntesis" -#: src/routes/party_.$partyId.transfer-debt.tsx:1085 +#: src/routes/party_.$partyId.transfer-debt.tsx:944 msgid "Opening updated expense…" msgstr "" @@ -1484,7 +1531,7 @@ msgid "Other operations" msgstr "Otras operaciones" #: src/routes/party_.$partyId.pay.tsx:104 -#: src/routes/party_.$partyId.transfer-debt.tsx:723 +#: src/routes/party_.$partyId.transfer-debt.tsx:636 #: src/routes/party.$partyId.tsx:986 msgid "owes" msgstr "le debe" @@ -1731,11 +1778,11 @@ msgstr "Sincronización en Tiempo Real" msgid "Recommendations" msgstr "" -#: src/routes/party_.$partyId.transfer-debt.tsx:885 +#: src/routes/party_.$partyId.transfer-debt.tsx:782 msgid "Recommended" msgstr "" -#: src/routes/party_.$partyId.transfer-debt.tsx:964 +#: src/routes/party_.$partyId.transfer-debt.tsx:852 msgid "Recreated in" msgstr "" @@ -1776,8 +1823,7 @@ msgstr "Restaurar al inicio" msgid "Retry" msgstr "Reintentar" -#: src/routes/party_.$partyId.transfer-debt.tsx:347 -#: src/routes/party_.$partyId.transfer-debt.tsx:652 +#: src/routes/party_.$partyId.transfer-debt.tsx:366 msgid "Review" msgstr "" @@ -1873,7 +1919,7 @@ msgstr "Configuración" msgid "Settings saved" msgstr "Configuración guardada" -#: src/routes/party_.$partyId.transfer-debt.tsx:953 +#: src/routes/party_.$partyId.transfer-debt.tsx:841 msgid "Settled in" msgstr "" @@ -1988,6 +2034,10 @@ msgstr "Comienza a registrar gastos con tu grupo" msgid "Stats" msgstr "Estadísticas" +#: src/routes/party_.$partyId.transfer-debt.tsx:586 +msgid "Step {currentStep} of {totalSteps}" +msgstr "" + #: src/routes/party_.$partyId.transfer-debt.tsx:448 msgid "Step 1" msgstr "" @@ -2108,7 +2158,7 @@ msgstr "Gracias por usar trizum. ¡Tus comentarios nos ayudan a mejorar!" msgid "The app works offline too!" msgstr "¡La app también funciona sin conexión!" -#: src/routes/party_.$partyId.transfer-debt.tsx:1074 +#: src/routes/party_.$partyId.transfer-debt.tsx:933 msgid "The new debt is now in <0>{destinationPartyName}, where <1>{destinationCounterpartyName} is owed by <2>{destinationDebtorName}." msgstr "" @@ -2134,7 +2184,7 @@ msgstr "Licencias de Terceros" msgid "This application uses the following open source libraries and tools. Below are their licenses and attributions." msgstr "Esta aplicación utiliza las siguientes bibliotecas y herramientas de código abierto. A continuación se muestran sus licencias y atribuciones." -#: src/routes/party_.$partyId.transfer-debt.tsx:234 +#: src/routes/party_.$partyId.transfer-debt.tsx:242 msgid "This debt is no longer available" msgstr "" @@ -2146,7 +2196,7 @@ msgstr "Esta imagen HEIC o HEIF no se pudo procesar. Prueba con otra foto o exp msgid "This isn't a valid ID" msgstr "Este no es un ID válido" -#: src/routes/party_.$partyId.transfer-debt.tsx:526 +#: src/routes/party_.$partyId.transfer-debt.tsx:434 msgid "This party needs another active participant besides you to receive the transferred debt." msgstr "" @@ -2154,6 +2204,10 @@ msgstr "" msgid "This project uses various open source libraries and tools." msgstr "Este proyecto utiliza varias bibliotecas y herramientas de código abierto." +#: src/routes/party_.$partyId.transfer-debt.tsx:276 +msgid "This will settle the debt in {originPartyName} and recreate it in {destinationPartyName}." +msgstr "" + #: src/routes/party_.$partyId.transfer-debt.tsx:296 msgid "This will settle the debt in <0>{0} and create the same debt in <1>{1}, where <2>{2} is owed by <3>{3}." msgstr "" @@ -2207,9 +2261,9 @@ msgstr "Total pagado por cada participante en el período seleccionado." msgid "Total spent" msgstr "Total gastado" -#: src/routes/party_.$partyId.transfer-debt.tsx:232 -#: src/routes/party_.$partyId.transfer-debt.tsx:243 -#: src/routes/party_.$partyId.transfer-debt.tsx:266 +#: src/routes/party_.$partyId.transfer-debt.tsx:240 +#: src/routes/party_.$partyId.transfer-debt.tsx:251 +#: src/routes/party_.$partyId.transfer-debt.tsx:272 #: src/routes/party.$partyId.tsx:1041 msgid "Transfer debt" msgstr "" @@ -2455,7 +2509,7 @@ msgstr "¡Sí! Tus datos se almacenan localmente en tu dispositivo y solo se sin msgid "You are" msgstr "" -#: src/routes/party_.$partyId.transfer-debt.tsx:795 +#: src/routes/party_.$partyId.transfer-debt.tsx:705 msgid "You are {currentParticipantName}" msgstr "" @@ -2463,7 +2517,7 @@ msgstr "" msgid "You can add photos to your expenses for better tracking." msgstr "Puedes añadir fotos a tus gastos para un mejor seguimiento." -#: src/routes/party_.$partyId.transfer-debt.tsx:246 +#: src/routes/party_.$partyId.transfer-debt.tsx:254 msgid "You can only transfer debt from actions where you are the one who owes the money." msgstr "" @@ -2471,7 +2525,7 @@ msgstr "" msgid "You left the party!" msgstr "¡Has dejado el grupo!" -#: src/routes/party_.$partyId.transfer-debt.tsx:443 +#: src/routes/party_.$partyId.transfer-debt.tsx:488 msgid "You need another active party with the same currency to transfer this debt." msgstr "" diff --git a/packages/pwa/src/routes/party_.$partyId.transfer-debt.tsx b/packages/pwa/src/routes/party_.$partyId.transfer-debt.tsx index 5f1bb285..b600b663 100644 --- a/packages/pwa/src/routes/party_.$partyId.transfer-debt.tsx +++ b/packages/pwa/src/routes/party_.$partyId.transfer-debt.tsx @@ -1,5 +1,5 @@ import { t } from "@lingui/core/macro"; -import { Plural, Trans } from "@lingui/react/macro"; +import { Trans } from "@lingui/react/macro"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import type { Currency } from "dinero.js"; import { AnimatePresence, motion } from "motion/react"; @@ -25,6 +25,7 @@ import { Alert, AlertDescription, AlertTitle } from "#src/ui/Alert.tsx"; import { Avatar } from "#src/ui/Avatar.tsx"; import { Button } from "#src/ui/Button.tsx"; import { Icon } from "#src/ui/Icon.tsx"; +import { IconButton } from "#src/ui/IconButton.tsx"; import { cn } from "#src/ui/utils.ts"; interface TransferDebtSearchParams { @@ -33,7 +34,7 @@ interface TransferDebtSearchParams { amount: number; } -type TransferStep = "configure" | "confirm" | "success"; +type TransferStep = "party" | "participant" | "confirm" | "success"; interface DestinationPartyOption { id: string; @@ -83,10 +84,7 @@ function RouteComponent() { const [destinationPartyId, setDestinationPartyId] = useState(""); const [destinationParticipantId, setDestinationParticipantId] = useState(""); - const [dismissedRecommendationIds, setDismissedRecommendationIds] = useState< - string[] - >([]); - const [step, setStep] = useState("configure"); + const [step, setStep] = useState("party"); const [isSubmitting, setIsSubmitting] = useState(false); const [successExpenseId, setSuccessExpenseId] = useState(null); @@ -145,15 +143,12 @@ function RouteComponent() { const selectedDestinationParty = destinationPartyOptions.find( ({ id }) => id === destinationPartyId, ); + const hasPartyStep = destinationPartyOptions.length > 1; const destinationParticipants = selectedDestinationParty?.otherParticipants ?? []; const selectedDestinationCounterparty = destinationParticipants.find( (participant) => participant.id === destinationParticipantId, ); - const displayedRecommendedParticipants = - selectedDestinationParty?.recommendedParticipants.filter( - (participant) => !dismissedRecommendationIds.includes(participant.id), - ) ?? []; const canTransfer = !!selectedDestinationParty && !!selectedDestinationCounterparty && @@ -180,11 +175,9 @@ function RouteComponent() { useEffect(() => { if (!selectedDestinationParty) { setDestinationParticipantId(""); - setDismissedRecommendationIds([]); return; } - setDismissedRecommendationIds([]); setDestinationParticipantId((currentValue) => { if ( selectedDestinationParty.otherParticipants.some( @@ -201,10 +194,25 @@ function RouteComponent() { }, [selectedDestinationParty]); useEffect(() => { - if (step !== "configure" && !canTransfer) { - setStep("configure"); + if (step === "success") { + return; + } + + if (!selectedDestinationParty) { + setStep("party"); + return; + } + + if (step === "party" && !hasPartyStep) { + setStep("participant"); } - }, [canTransfer, step]); + }, [hasPartyStep, selectedDestinationParty, step]); + + useEffect(() => { + if (step === "confirm" && !canTransfer) { + setStep(selectedDestinationParty ? "participant" : "party"); + } + }, [canTransfer, selectedDestinationParty, step]); useEffect(() => { if (step !== "success" || !successExpenseId) { @@ -256,23 +264,26 @@ function RouteComponent() { selectedDestinationParty?.currentParticipant.name ?? ""; const selectedDestinationCounterpartyName = selectedDestinationCounterparty?.name ?? ""; - const selectedExactMatchParticipantName = - selectedDestinationParty?.exactMatchParticipant?.name ?? ""; const pageTitle = step === "confirm" ? t`Confirm transfer` : step === "success" ? t`Debt transferred` : t`Transfer debt`; - - function handleRecommendationPress(participantId: string) { - setDestinationParticipantId(participantId); - setDismissedRecommendationIds((currentValue) => - currentValue.includes(participantId) - ? currentValue - : [...currentValue, participantId], - ); - } + const participantStepDescription = selectedDestinationParty + ? t`In ${destinationPartyName}, ${destinationCurrentParticipantName} will owe the selected person.` + : t`Choose who should be owed after the transfer.`; + const reviewStepDescription = t`This will settle the debt in ${originPartyName} and recreate it in ${destinationPartyName}.`; + const onBackPress = + step === "confirm" + ? () => { + setStep("participant"); + } + : step === "participant" && hasPartyStep + ? () => { + setStep("party"); + } + : undefined; async function onConfirmTransfer() { if (!selectedDestinationParty || !selectedDestinationCounterparty) { @@ -303,19 +314,27 @@ function RouteComponent() { } return ( - -
- -
+ + {step === "party" || step === "participant" ? ( +
+ +
+ ) : null} -
- -
+ {step !== "success" ? ( +
+ +
+ ) : null} {step === "success" ? ( @@ -345,8 +364,8 @@ function RouteComponent() {
-
-
- - - Expenses that will be created - -
- -
- - -
-
- -
- - +
+ ) : step === "participant" ? ( + +
+ + + {destinationParticipants.length === 0 ? ( + + ) : ( +
+ {destinationParticipants.map((participant) => ( + candidate.id === participant.id, + ) ?? false + } + isExactMatch={ + selectedDestinationParty?.exactMatchParticipant?.id === + participant.id + } + isSelected={participant.id === destinationParticipantId} + participant={participant} + onPress={() => { + setDestinationParticipantId(participant.id); + }} + /> + ))} +
+ )} + + +
+
) : (
{destinationPartyOptions.length === 0 ? ( @@ -445,9 +490,9 @@ function RouteComponent() { ) : ( <>
@@ -459,152 +504,11 @@ function RouteComponent() { sourceCreditorName={sourceCreditorName} onPress={() => { setDestinationPartyId(option.id); + setStep("participant"); }} /> ))}
- - {selectedDestinationParty ? ( - <> - - - {selectedDestinationParty.exactMatchParticipant ? ( - - - - - Exact name match selected automatically:{" "} - {selectedExactMatchParticipantName} - - - - ) : null} - - {displayedRecommendedParticipants.length > 0 ? ( -
-
- Quick recommendations -
- -
- - {displayedRecommendedParticipants.map( - (participant) => ( - { - handleRecommendationPress(participant.id); - }} - > - - {participant.name} - - ), - )} - -
-
- ) : null} - - {destinationParticipants.length === 0 ? ( - - ) : ( -
- {destinationParticipants.map((participant) => ( - candidate.id === participant.id, - )} - isExactMatch={ - selectedDestinationParty.exactMatchParticipant - ?.id === participant.id - } - isSelected={ - participant.id === destinationParticipantId - } - participant={participant} - onPress={() => { - setDestinationParticipantId(participant.id); - }} - /> - ))} -
- )} - - {selectedDestinationCounterparty ? ( -
-
- - - Preview - -
- -

- - This will settle the debt in{" "} - - {originPartyName} - {" "} - and recreate it in{" "} - - {destinationPartyName} - - , where{" "} - - {selectedDestinationCounterpartyName} - {" "} - will be owed by{" "} - - {destinationCurrentParticipantName} - - . - -

-
- ) : null} - - - - ) : null} )}
@@ -621,16 +525,27 @@ function TransferDebtLayout({ title, children, showBackButton = true, + onBackPress, }: { title: string; children: React.ReactNode; showBackButton?: boolean; + onBackPress?: () => void; }) { return (
{showBackButton ? ( - + onBackPress ? ( + + ) : ( + + ) ) : (
)} @@ -642,41 +557,39 @@ function TransferDebtLayout({ ); } -function TransferStepIndicator({ step }: { step: TransferStep }) { - return ( -
- - / - - / - -
- ); -} +function TransferStepIndicator({ + step, + hasPartyStep, +}: { + step: Exclude; + hasPartyStep: boolean; +}) { + const totalSteps = hasPartyStep ? 3 : 2; + const currentStep = + step === "party" + ? 1 + : step === "participant" + ? hasPartyStep + ? 2 + : 1 + : totalSteps; + const currentStepLabel = + step === "party" + ? t`Choose a party` + : step === "participant" + ? t`Choose a person` + : t`Confirm`; -function StepDot({ isActive, label }: { isActive: boolean; label: string }) { return ( - - - {label} - +
+ + + Step {currentStep} of {totalSteps} + + + + {currentStepLabel} +
); } @@ -788,27 +701,11 @@ function DestinationPartyCard({ ) : null}
-
- - - - You are {currentParticipantName} - - - - - - - - - +
+ You are {currentParticipantName}
-
+
{option.exactMatchParticipant ? ( Best match for {sourceCreditorName}:{" "} @@ -818,11 +715,11 @@ function DestinationPartyCard({ ) : topRecommendation ? ( - Likely match for {sourceCreditorName}:{" "} + Best match for {sourceCreditorName}:{" "} {topRecommendationName} ) : ( - No obvious match for {sourceCreditorName} yet + Choose the person on the next step )}
@@ -886,15 +783,6 @@ function DestinationParticipantCard({ ) : null} - - {isSelected ? ( - - - - Selected - - - ) : null}
@@ -1003,35 +891,6 @@ function ReviewPartyRow({ ); } -function ExpensePreviewCard({ - label, - partyName, - expenseName, - detail, -}: { - label: string; - partyName: string; - expenseName: string; - detail: string; -}) { - return ( -
-
- {label} -
-
- {partyName} -
-
- {expenseName} -
-
- {detail} -
-
- ); -} - function TransferSuccessState({ destinationPartyName, destinationCounterpartyName,