Skip to content
Merged
2 changes: 1 addition & 1 deletion src/components/CreateStreamModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 5 additions & 3 deletions src/components/RecentStreams.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 (
Expand Down
41 changes: 25 additions & 16 deletions src/components/StreamTimeline.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -39,6 +40,7 @@ type TransactionStatus = "idle" | "pending" | "confirmed" | "rejected" | "timeou
const TransactionDemo: React.FC<{
mockOutcome: Exclude<TransactionStatus, "idle" | "pending">;
}> = ({ mockOutcome }) => {
const { t } = useTranslation();
const [status, setStatus] = React.useState<TransactionStatus>("idle");
const [message, setMessage] = React.useState(
"Transaction state idle. Click submit to start.",
Expand All @@ -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";
Expand Down Expand Up @@ -134,6 +140,7 @@ export const StreamTimeline: React.FC<StreamTimelineProps> = ({
showTransactionDemo = false,
transactionDemoOutcome = "confirmed",
}) => {
const { t } = useTranslation();
const [animateClass, setAnimateClass] = React.useState("");
const prevStatusRef = React.useRef(status);

Expand Down Expand Up @@ -224,14 +231,16 @@ export const StreamTimeline: React.FC<StreamTimelineProps> = ({
<div className="stream-timeline__sr-summary" role="doc-subtitle">
<h3 className="sr-only">Timeline Summary</h3>
<ul className="sr-only">
<li>Start date: {formatDate(start)}</li>
{cliff && <li>Cliff end date: {formatDate(cliff)}</li>}
<li>Current date: {formatDate(current)}</li>
<li>End date: {formatDate(end)}</li>
<li>Stream status: {status}</li>
<li>Progress: {accrualPercent.toFixed(0)}% complete</li>
<li>Withdrawable: ${formatNumber(withdrawableAmount)}</li>
<li>Total amount: ${formatNumber(totalAmount)}</li>
<li>{t("streamTimeline.startDate", { date: formatDate(start) })}</li>
{cliff && (
<li>{t("streamTimeline.cliffEndDate", { date: formatDate(cliff) })}</li>
)}
<li>{t("streamTimeline.currentDate", { date: formatDate(current) })}</li>
<li>{t("streamTimeline.endDate", { date: formatDate(end) })}</li>
<li>{t("streamTimeline.streamStatus", { status })}</li>
<li>{t("streamTimeline.progress", { count: Math.round(accrualPercent), percent: accrualPercent.toFixed(0) })}</li>
<li>{t("streamTimeline.withdrawable", { count: withdrawableAmount, amount: formatNumber(withdrawableAmount) })}</li>
<li>{t("streamTimeline.totalAmount", { count: totalAmount, amount: formatNumber(totalAmount) })}</li>
</ul>
</div>

Expand Down
15 changes: 13 additions & 2 deletions src/components/__tests__/RecentStreams.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,19 @@ describe('RecentStreams', () => {
renderWithRouter(<RecentStreams streams={streams} />);

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(<RecentStreams streams={streams} />);

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', () => {
Expand Down
61 changes: 61 additions & 0 deletions src/components/__tests__/StreamTimeline.duration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>, path: string) {
validatePlural(messages.streamCount, pluralRequired.streamCount, 'streamCount', path);

const batchResults = messages.batchResults as Record<string, unknown> | 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<string, any>;

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<string, unknown>;
validateMessages(messages, path);
}
});
});
4 changes: 3 additions & 1 deletion src/components/csv-upload/PreviewValidateStep.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -301,6 +302,7 @@ const PreviewValidateStep: React.FC<PreviewValidateStepProps> = ({
const [liveMessage, setLiveMessage] = useState('');
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
const fixButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const { t } = useTranslation();

const validCount = rows.filter((r) => r.status === 'valid').length;
const errorCount = rows.filter((r) => r.status === 'needs-fix').length;
Expand Down Expand Up @@ -383,7 +385,7 @@ const PreviewValidateStep: React.FC<PreviewValidateStepProps> = ({
);
markDuplicates(newRows);
onRowsChange(newRows);
setLiveMessage(`${errorCount} invalid rows skipped.`);
setLiveMessage(t('invalidRowsSkipped', { count: errorCount }));
}, [rows, onRowsChange, errorCount]);

const handleReplaceClick = useCallback(() => {
Expand Down
80 changes: 80 additions & 0 deletions src/components/csv-upload/__tests__/PreviewValidateStep.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<PreviewValidateStep
rows={rows}
onRowsChange={onRowsChange}
onReview={vi.fn()}
onReplaceFile={vi.fn()}
/>,
);

const skipAllBtn = screen.getByRole("button", {
name: "Skip invalid rows",
});
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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(
<PreviewValidateStep
rows={rows}
onRowsChange={vi.fn()}
onReview={vi.fn()}
onReplaceFile={vi.fn()}
/>,
);

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(
<PreviewValidateStep
rows={rows}
onRowsChange={vi.fn()}
onReview={vi.fn()}
onReplaceFile={vi.fn()}
/>,
);

expect(screen.getByText("Reviewing 2 streams")).toBeInTheDocument();
});
});