From a273525f6fef6c03eda28f1fe82c17bb684a590c Mon Sep 17 00:00:00 2001 From: Idowu Fathiu Ayomide Date: Mon, 31 Aug 2026 11:06:29 +0100 Subject: [PATCH 1/7] fix: Add pluralized translations for stream counts and batch resu (#1570) --- src/components/csv-upload/PreviewValidateStep.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/csv-upload/PreviewValidateStep.tsx b/src/components/csv-upload/PreviewValidateStep.tsx index 39e250ec..ae654c02 100644 --- a/src/components/csv-upload/PreviewValidateStep.tsx +++ b/src/components/csv-upload/PreviewValidateStep.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import './PreviewValidateStep.css'; import type { CanonicalHeader, CsvRow } from './types'; import { validateRow, markDuplicates } from './csvParser'; @@ -301,6 +302,7 @@ const PreviewValidateStep: React.FC = ({ const [liveMessage, setLiveMessage] = useState(''); const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); const fixButtonRefs = useRef>({}); + const { t } = useTranslation(); const validCount = rows.filter((r) => r.status === 'valid').length; const errorCount = rows.filter((r) => r.status === 'needs-fix').length; @@ -383,7 +385,7 @@ const PreviewValidateStep: React.FC = ({ ); markDuplicates(newRows); onRowsChange(newRows); - setLiveMessage(`${errorCount} invalid rows skipped.`); + setLiveMessage(t('invalidRowsSkipped', { count: errorCount })); }, [rows, onRowsChange, errorCount]); const handleReplaceClick = useCallback(() => { From 66bddb7f0771bacd7a21db7e66b7ad1e77464b77 Mon Sep 17 00:00:00 2001 From: Idowu Fathiu Ayomide Date: Mon, 31 Aug 2026 11:06:32 +0100 Subject: [PATCH 2/7] fix: Add pluralized translations for stream counts and batch resu (#1570) --- .../__tests__/PreviewValidateStep.test.tsx | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/components/csv-upload/__tests__/PreviewValidateStep.test.tsx b/src/components/csv-upload/__tests__/PreviewValidateStep.test.tsx index 22653ab1..f649dace 100644 --- a/src/components/csv-upload/__tests__/PreviewValidateStep.test.tsx +++ b/src/components/csv-upload/__tests__/PreviewValidateStep.test.tsx @@ -319,6 +319,50 @@ describe("PreviewValidateStep — duplicate handling, validation, and actions", />, ); + const skipAllBtn = screen.getByRole("button", { + name: "Skip invalid row", + }); + fireEvent.click(skipAllBtn); + + expect(onRowsChange).toHaveBeenCalledTimes(1); + const updatedRows: CsvRow[] = onRowsChange.mock.calls[0][0]; + expect(updatedRows[0].status).toBe("valid"); + expect(updatedRows[1].status).toBe("skipped"); + }); + + it("skips multiple invalid rows via the bulk skip button", () => { + const onRowsChange = vi.fn(); + const rows: CsvRow[] = [ + createRow({ + id: "r1", + rowNumber: 1, + status: "valid", + }), + createRow({ + id: "r2", + rowNumber: 2, + recipient: "invalid-address-2", + status: "needs-fix", + fieldErrors: { recipient: "Invalid Stellar address" }, + }), + createRow({ + id: "r3", + rowNumber: 3, + recipient: "invalid-address-3", + status: "needs-fix", + fieldErrors: { recipient: "Invalid Stellar address" }, + }), + ]; + + render( + , + ); + const skipAllBtn = screen.getByRole("button", { name: "Skip invalid rows", }); @@ -328,6 +372,7 @@ describe("PreviewValidateStep — duplicate handling, validation, and actions", const updatedRows: CsvRow[] = onRowsChange.mock.calls[0][0]; expect(updatedRows[0].status).toBe("valid"); expect(updatedRows[1].status).toBe("skipped"); + expect(updatedRows[2].status).toBe("skipped"); }); it("renders loading state when isLoading is true", () => { @@ -421,4 +466,39 @@ describe("PreviewValidateStep — duplicate handling, validation, and actions", screen.getByText("No valid rows found in this file."), ).toBeInTheDocument(); }); + + it("renders singular stream count for a single row", () => { + const rows: CsvRow[] = [ + createRow({ id: "r1", rowNumber: 1, status: "valid" }), + ]; + + render( + , + ); + + expect(screen.getByText("Reviewing 1 stream")).toBeInTheDocument(); + }); + + it("renders plural stream count for multiple rows", () => { + const rows: CsvRow[] = [ + createRow({ id: "r1", rowNumber: 1, status: "valid" }), + createRow({ id: "r2", rowNumber: 2, status: "valid" }), + ]; + + render( + , + ); + + expect(screen.getByText("Reviewing 2 streams")).toBeInTheDocument(); + }); }); From be7bba8e5d15c4ddb87207b6d2fa594cc26b3a83 Mon Sep 17 00:00:00 2001 From: Idowu Fathiu Ayomide Date: Mon, 31 Aug 2026 11:06:39 +0100 Subject: [PATCH 3/7] fix: Add pluralized translations for stream counts and batch resu (#1570) --- src/components/CreateStreamModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CreateStreamModal.tsx b/src/components/CreateStreamModal.tsx index 308ac543..d7870788 100644 --- a/src/components/CreateStreamModal.tsx +++ b/src/components/CreateStreamModal.tsx @@ -129,7 +129,7 @@ function formatReviewDeposit(value: string): string { /** Formats the daily duration unit with singular/plural copy. */ function formatDurationUnit(value: string, t: any): string { const count = parseStreamNumber(value); - return count === 1 ? t("createStream.duration.day_one") : t("createStream.duration.day_other", { count }); + return t("createStream.duration.day", { count }); } function validateAccrualRate(value: string, t: any): string | undefined { From bb20d1793a5709c740a8b560472f224ff9db509a Mon Sep 17 00:00:00 2001 From: Idowu Fathiu Ayomide Date: Mon, 31 Aug 2026 11:06:41 +0100 Subject: [PATCH 4/7] fix: Add pluralized translations for stream counts and batch resu (#1570) --- src/components/RecentStreams.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/RecentStreams.tsx b/src/components/RecentStreams.tsx index c238fca7..5a28c550 100644 --- a/src/components/RecentStreams.tsx +++ b/src/components/RecentStreams.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; export type StreamStatus = 'Active' | 'Paused' | 'Completed'; @@ -43,18 +44,19 @@ export default function RecentStreams({ onRetry, walletConnected = false }: RecentStreamsProps) { + const { t } = useTranslation(); const [announcement, setAnnouncement] = useState(''); useEffect(() => { if (streams.length > 0) { - setAnnouncement(`Found ${streams.length} matching streams.`); + setAnnouncement(t('recentStreams.foundMatchingStreams', { count: streams.length })); } else { - setAnnouncement('No matching streams found.'); + setAnnouncement(t('recentStreams.foundMatchingStreams', { count: 0 })); } const timer = setTimeout(() => setAnnouncement(''), 1000); return () => clearTimeout(timer); - }, [streams.length]); + }, [streams.length, t]); if (loading) { return ( From 823076e4fe9588667ea59575cbd723630281e3ec Mon Sep 17 00:00:00 2001 From: Idowu Fathiu Ayomide Date: Mon, 31 Aug 2026 11:06:43 +0100 Subject: [PATCH 5/7] fix: Add pluralized translations for stream counts and batch resu (#1570) --- src/components/__tests__/RecentStreams.test.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/__tests__/RecentStreams.test.tsx b/src/components/__tests__/RecentStreams.test.tsx index 8582ed59..9236f718 100644 --- a/src/components/__tests__/RecentStreams.test.tsx +++ b/src/components/__tests__/RecentStreams.test.tsx @@ -54,8 +54,19 @@ describe('RecentStreams', () => { renderWithRouter(); const liveRegion = document.querySelector('[aria-live="polite"]') as HTMLElement; - expect(liveRegion).toBeTruthy(); - expect(liveRegion.textContent).toBe('Found 1 matching streams.'); + expect(liveRegion.textContent).toBe('Found 1 matching stream.'); + }); + + it('uses the plural form for multiple matching streams', () => { + const streams = [ + makeStream({ id: 'stream-2' }), + makeStream({ id: 'stream-3' }), + makeStream({ id: 'stream-4' }), + ]; + renderWithRouter(); + + const liveRegion = document.querySelector('[aria-live="polite"]') as HTMLElement; + expect(liveRegion.textContent).toBe('Found 3 matching streams.'); }); it('sets the announcement to "No matching streams found." for an empty list', () => { From 81baa54bfaced303505c3e27826d8833112d3d05 Mon Sep 17 00:00:00 2001 From: Idowu Fathiu Ayomide Date: Mon, 31 Aug 2026 11:06:45 +0100 Subject: [PATCH 6/7] fix: Add pluralized translations for stream counts and batch resu (#1570) --- src/components/StreamTimeline.tsx | 41 +++++++++++++++++++------------ 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/components/StreamTimeline.tsx b/src/components/StreamTimeline.tsx index 5c7da117..0e8a73b8 100644 --- a/src/components/StreamTimeline.tsx +++ b/src/components/StreamTimeline.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import "./StreamTimeline.module.css"; import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion"; import { createDateTimeFormat, formatNumber } from "../lib/formatters"; @@ -39,6 +40,7 @@ type TransactionStatus = "idle" | "pending" | "confirmed" | "rejected" | "timeou const TransactionDemo: React.FC<{ mockOutcome: Exclude; }> = ({ mockOutcome }) => { + const { t } = useTranslation(); const [status, setStatus] = React.useState("idle"); const [message, setMessage] = React.useState( "Transaction state idle. Click submit to start.", @@ -53,17 +55,21 @@ const TransactionDemo: React.FC<{ React.useEffect(() => { if (status !== "pending") return; const timer = setTimeout(() => { + const successCount = mockOutcome === "confirmed" ? 1 : 0; + const failureCount = + mockOutcome === "rejected" || mockOutcome === "timeout" ? 1 : 0; + const skippedCount = 0; setStatus(mockOutcome); - if (mockOutcome === "confirmed") { - setMessage("Transaction confirmed successfully!"); - } else if (mockOutcome === "rejected") { - setMessage("Transaction rejected. Please review the error and retry."); - } else { - setMessage("Transaction timed out. Please retry."); - } + setMessage( + [ + t("transactionDemo.successes", { count: successCount }), + t("transactionDemo.failures", { count: failureCount }), + t("transactionDemo.skipped", { count: skippedCount }), + ].join(", "), + ); }, 1200); return () => clearTimeout(timer); - }, [status, mockOutcome]); + }, [status, mockOutcome, t]); const isPending = status === "pending"; const isFailed = status === "rejected" || status === "timeout"; @@ -134,6 +140,7 @@ export const StreamTimeline: React.FC = ({ showTransactionDemo = false, transactionDemoOutcome = "confirmed", }) => { + const { t } = useTranslation(); const [animateClass, setAnimateClass] = React.useState(""); const prevStatusRef = React.useRef(status); @@ -224,14 +231,16 @@ export const StreamTimeline: React.FC = ({

Timeline Summary

    -
  • Start date: {formatDate(start)}
  • - {cliff &&
  • Cliff end date: {formatDate(cliff)}
  • } -
  • Current date: {formatDate(current)}
  • -
  • End date: {formatDate(end)}
  • -
  • Stream status: {status}
  • -
  • Progress: {accrualPercent.toFixed(0)}% complete
  • -
  • Withdrawable: ${formatNumber(withdrawableAmount)}
  • -
  • Total amount: ${formatNumber(totalAmount)}
  • +
  • {t("streamTimeline.startDate", { date: formatDate(start) })}
  • + {cliff && ( +
  • {t("streamTimeline.cliffEndDate", { date: formatDate(cliff) })}
  • + )} +
  • {t("streamTimeline.currentDate", { date: formatDate(current) })}
  • +
  • {t("streamTimeline.endDate", { date: formatDate(end) })}
  • +
  • {t("streamTimeline.streamStatus", { status })}
  • +
  • {t("streamTimeline.progress", { count: Math.round(accrualPercent), percent: accrualPercent.toFixed(0) })}
  • +
  • {t("streamTimeline.withdrawable", { count: withdrawableAmount, amount: formatNumber(withdrawableAmount) })}
  • +
  • {t("streamTimeline.totalAmount", { count: totalAmount, amount: formatNumber(totalAmount) })}
From 0a2b6ca441c6f746ae22d385563fd297bd0357c5 Mon Sep 17 00:00:00 2001 From: Idowu Fathiu Ayomide Date: Mon, 31 Aug 2026 11:06:46 +0100 Subject: [PATCH 7/7] fix: Add pluralized translations for stream counts and batch resu (#1570) --- .../StreamTimeline.duration.test.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/components/__tests__/StreamTimeline.duration.test.tsx b/src/components/__tests__/StreamTimeline.duration.test.tsx index 969100f0..1de2c157 100644 --- a/src/components/__tests__/StreamTimeline.duration.test.tsx +++ b/src/components/__tests__/StreamTimeline.duration.test.tsx @@ -33,3 +33,64 @@ describe('StreamTimeline Duration', () => { expect(screen.getByText('Invalid date configuration')).toBeInTheDocument(); }); }); + +// New tests for pluralized translation keys +const pluralRequired = { + streamCount: ['zero', 'one', 'many'], + batchResults: { + successes: ['zero', 'one', 'many'], + failures: ['zero', 'one', 'many'], + skipped: ['zero', 'one', 'many'], + }, +} as const; + +function hasPluralForms(value: unknown, forms: string[]): boolean { + if (typeof value === 'string') { + // Accept ICU plural syntax or simple {{count}} placeholder + return /plural|{{count}}/.test(value); + } + if (typeof value === 'object' && value !== null) { + const obj = value as Record; + return forms.every((form) => form in obj); + } + return false; +} + +function validatePlural( + value: unknown, + forms: string[], + messageKey: string, + path: string +) { + expect(value, `Missing ${messageKey} in ${path}`).toBeDefined(); + expect( + hasPluralForms(value, forms), + `${messageKey} in ${path} is missing plural forms (${forms.join(', ')})` + ).toBe(true); +} + +function validateMessages(messages: Record, path: string) { + validatePlural(messages.streamCount, pluralRequired.streamCount, 'streamCount', path); + + const batchResults = messages.batchResults as Record | undefined; + expect(batchResults, `Missing batchResults in ${path}`).toBeDefined(); + if (batchResults) { + for (const [subKey, forms] of Object.entries(pluralRequired.batchResults)) { + const key = `batchResults.${subKey}`; + validatePlural(batchResults[subKey], forms, key, path); + } + } +} + +describe('Pluralized translations', () => { + const localeFiles = import.meta.glob('../../locales/*.json', { eager: true }) as Record; + + it('defines pluralized stream count and batch result messages for all supported locales', () => { + const files = Object.entries(localeFiles); + expect(files.length).toBeGreaterThan(0); + for (const [path, module] of files) { + const messages = (module.default ?? module) as Record; + validateMessages(messages, path); + } + }); +}); \ No newline at end of file