diff --git a/src/__tests__/InvoiceExportButton.test.tsx b/src/__tests__/InvoiceExportButton.test.tsx
new file mode 100644
index 0000000..c986d70
--- /dev/null
+++ b/src/__tests__/InvoiceExportButton.test.tsx
@@ -0,0 +1,139 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import InvoiceExportButton from '@/components/InvoiceExportButton';
+import type { Invoice } from '@stellar-split/sdk';
+
+// Mock the dynamic PDF import
+jest.mock('@react-pdf/renderer', () => ({
+ pdf: jest.fn(() => ({
+ toBlob: jest.fn(async () => new Blob(['test'])),
+ })),
+ Document: jest.fn(() => null),
+ Page: jest.fn(() => null),
+ Text: jest.fn(() => null),
+ View: jest.fn(() => null),
+ StyleSheet: { create: jest.fn((obj) => obj) },
+ Image: jest.fn(() => null),
+}));
+
+jest.mock('@/lib/branding', () => ({
+ fetchBrandSettings: jest.fn(async () => ({})),
+}));
+
+describe('InvoiceExportButton', () => {
+ const mockInvoice: Invoice = {
+ id: 'test-id',
+ creator: 'GTEST',
+ recipients: [
+ { address: 'GRECIPIENT', amount: 100n },
+ ],
+ payments: [],
+ status: 'Pending',
+ funded: 0n,
+ token: 'USDC',
+ deadline: 0,
+ };
+
+ beforeEach(() => {
+ global.URL.createObjectURL = jest.fn(() => 'blob:test');
+ global.URL.revokeObjectURL = jest.fn();
+ HTMLAnchorElement.prototype.click = jest.fn();
+
+ jest.spyOn(global, 'fetch').mockResolvedValue({
+ ok: true,
+ json: async () => ({}),
+ } as any);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render the export button', () => {
+ render(
+
+ );
+
+ expect(screen.getByRole('button', { name: /export pdf/i })).toBeInTheDocument();
+ });
+
+ it('should be enabled initially', () => {
+ render(
+
+ );
+
+ const button = screen.getByRole('button', { name: /export pdf/i });
+ expect(button).not.toBeDisabled();
+ });
+
+ it('should be disabled while export is in progress', async () => {
+ render(
+
+ );
+
+ const button = screen.getByRole('button', { name: /export pdf/i });
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(button).toBeDisabled();
+ });
+ });
+
+ it('should show spinner icon while loading', async () => {
+ render(
+
+ );
+
+ const button = screen.getByRole('button', { name: /export pdf/i });
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ const spinner = button.querySelector('svg.animate-spin');
+ expect(spinner).toBeInTheDocument();
+ });
+ });
+
+ it('should show generating text while loading', async () => {
+ render(
+
+ );
+
+ const button = screen.getByRole('button', { name: /export pdf/i });
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(button).toHaveTextContent('Generating…');
+ });
+ });
+
+ it('should return to normal state after export completes', async () => {
+ render(
+
+ );
+
+ const button = screen.getByRole('button', { name: /export pdf/i });
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(button).not.toBeDisabled();
+ expect(button).toHaveTextContent('Export PDF');
+ });
+ });
+});
diff --git a/src/__tests__/InvoiceListSentinel.test.tsx b/src/__tests__/InvoiceListSentinel.test.tsx
new file mode 100644
index 0000000..3997dd6
--- /dev/null
+++ b/src/__tests__/InvoiceListSentinel.test.tsx
@@ -0,0 +1,116 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import InvoiceListSentinel from '@/components/InvoiceListSentinel';
+
+describe('InvoiceListSentinel', () => {
+ let observerMock: { observe: jest.Mock; disconnect: jest.Mock; unobserve: jest.Mock };
+ let IntersectionObserverMock: jest.Mock;
+
+ beforeEach(() => {
+ observerMock = {
+ observe: jest.fn(),
+ disconnect: jest.fn(),
+ unobserve: jest.fn(),
+ };
+
+ IntersectionObserverMock = jest.fn(() => observerMock);
+ (global as any).IntersectionObserver = IntersectionObserverMock;
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should create an IntersectionObserver on mount', () => {
+ const mockOnVisible = jest.fn();
+ render(
+
+ );
+
+ expect(IntersectionObserverMock).toHaveBeenCalled();
+ expect(observerMock.observe).toHaveBeenCalled();
+ });
+
+ it('should disconnect the IntersectionObserver on unmount', () => {
+ const mockOnVisible = jest.fn();
+ const { unmount } = render(
+
+ );
+
+ unmount();
+
+ expect(observerMock.disconnect).toHaveBeenCalled();
+ });
+
+ it('should call onVisible when intersection is detected', () => {
+ const mockOnVisible = jest.fn();
+ IntersectionObserverMock.mockImplementation((callback) => {
+ setTimeout(() => {
+ callback([{ isIntersecting: true }] as any);
+ }, 0);
+ return observerMock;
+ });
+
+ render(
+
+ );
+
+ waitFor(() => {
+ expect(mockOnVisible).toHaveBeenCalled();
+ });
+ });
+
+ it('should display loading spinner when loading is true', () => {
+ const mockOnVisible = jest.fn();
+ render(
+
+ );
+
+ expect(screen.getByText('Loading more invoices…')).toBeInTheDocument();
+ });
+
+ it('should display all loaded message when allLoaded is true', () => {
+ const mockOnVisible = jest.fn();
+ render(
+
+ );
+
+ expect(screen.getByText('All invoices loaded')).toBeInTheDocument();
+ });
+
+ it('should pass custom rootMargin to IntersectionObserver', () => {
+ const mockOnVisible = jest.fn();
+ const customMargin = '500px';
+
+ render(
+
+ );
+
+ const callArgs = IntersectionObserverMock.mock.calls[0];
+ expect(callArgs[1]).toEqual({ rootMargin: customMargin });
+ });
+});
diff --git a/src/__tests__/InvoiceTimeline.test.tsx b/src/__tests__/InvoiceTimeline.test.tsx
new file mode 100644
index 0000000..467dfc8
--- /dev/null
+++ b/src/__tests__/InvoiceTimeline.test.tsx
@@ -0,0 +1,177 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import InvoiceTimeline from '@/components/InvoiceTimeline';
+import type { InvoiceEvent } from '@/components/InvoiceTimeline';
+
+// Mock the fetchEvents function
+jest.mock('@/components/InvoiceTimeline', () => {
+ const actual = jest.requireActual('@/components/InvoiceTimeline');
+ return actual;
+});
+
+jest.mock('@/components/WalletAddress', () => {
+ return function MockWalletAddress({ address }: any) {
+ return {address};
+ };
+});
+
+jest.mock('@/components/ui/RelativeTime', () => {
+ return function MockRelativeTime({ iso }: any) {
+ return {new Date(iso).toLocaleString()};
+ };
+});
+
+describe('InvoiceTimeline', () => {
+ const mockEvents: InvoiceEvent[] = [
+ {
+ type: 'Created',
+ description: 'Invoice created',
+ timestamp: Math.floor(Date.now() / 1000),
+ actor: 'GCREATOR',
+ txHash: 'abc123def456',
+ },
+ {
+ type: 'PaymentReceived',
+ description: '100 USDC received',
+ timestamp: Math.floor(Date.now() / 1000) - 3600,
+ actor: 'GPAYER',
+ txHash: 'def789ghi012',
+ },
+ ];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render timeline events', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByText(/Created/i)).toBeInTheDocument();
+ });
+ });
+
+ it('should render show details button for events with details', async () => {
+ render();
+
+ await waitFor(() => {
+ const showDetailsButtons = screen.getAllByText(/Show details/i);
+ expect(showDetailsButtons.length).toBeGreaterThan(0);
+ });
+ });
+
+ it('should expand details when show details button is clicked', async () => {
+ render();
+
+ await waitFor(() => {
+ const showDetailsButton = screen.getAllByText(/Show details/i)[0];
+ fireEvent.click(showDetailsButton);
+
+ expect(showDetailsButton).toHaveTextContent(/Hide details/i);
+ });
+ });
+
+ it('should collapse details when hide details button is clicked', async () => {
+ render();
+
+ await waitFor(() => {
+ const showDetailsButton = screen.getAllByText(/Show details/i)[0];
+ fireEvent.click(showDetailsButton);
+
+ const hideDetailsButton = screen.getByText(/Hide details/i);
+ fireEvent.click(hideDetailsButton);
+
+ expect(screen.getByText(/Show details/i)).toBeInTheDocument();
+ });
+ });
+
+ it('should display actor address when details are expanded', async () => {
+ render();
+
+ await waitFor(() => {
+ const showDetailsButtons = screen.getAllByText(/Show details/i);
+ if (showDetailsButtons.length > 0) {
+ fireEvent.click(showDetailsButtons[0]);
+
+ expect(screen.getByText(/GCREATOR|GPAYER/)).toBeInTheDocument();
+ }
+ });
+ });
+
+ it('should display transaction hash link when details are expanded', async () => {
+ render();
+
+ await waitFor(() => {
+ const showDetailsButtons = screen.getAllByText(/Show details/i);
+ if (showDetailsButtons.length > 0) {
+ fireEvent.click(showDetailsButtons[0]);
+
+ const txLinks = screen.getAllByRole('link');
+ expect(txLinks.length).toBeGreaterThan(0);
+ }
+ });
+ });
+
+ it('should have correct aria-expanded attribute', async () => {
+ render();
+
+ await waitFor(() => {
+ const showDetailsButtons = screen.getAllByText(/Show details/i);
+ if (showDetailsButtons.length > 0) {
+ const button = showDetailsButtons[0].closest('button');
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+
+ fireEvent.click(showDetailsButtons[0]);
+
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ }
+ });
+ });
+
+ it('should apply animation classes to details section', async () => {
+ const { container } = render();
+
+ await waitFor(() => {
+ const detailsSections = container.querySelectorAll('.timeline-details');
+ expect(detailsSections.length).toBeGreaterThan(0);
+
+ detailsSections.forEach((section) => {
+ expect(section.classList.contains('collapsed')).toBe(true);
+ });
+ });
+ });
+
+ it('should toggle animation classes when expanding', async () => {
+ const { container } = render();
+
+ await waitFor(() => {
+ const showDetailsButtons = screen.getAllByText(/Show details/i);
+ if (showDetailsButtons.length > 0) {
+ const detailsSection = container.querySelector('.timeline-details');
+ expect(detailsSection?.classList.contains('collapsed')).toBe(true);
+
+ fireEvent.click(showDetailsButtons[0]);
+
+ waitFor(() => {
+ expect(detailsSection?.classList.contains('expanded')).toBe(true);
+ });
+ }
+ });
+ });
+
+ it('should render animation CSS rules', async () => {
+ const { container } = render();
+
+ const styleTag = container.querySelector('style');
+ expect(styleTag).toBeInTheDocument();
+ expect(styleTag?.textContent).toContain('timeline-details');
+ expect(styleTag?.textContent).toContain('max-height');
+ expect(styleTag?.textContent).toContain('transition');
+ });
+
+ it('should respect prefers-reduced-motion', async () => {
+ const { container } = render();
+
+ const styleTag = container.querySelector('style');
+ expect(styleTag?.textContent).toContain('prefers-reduced-motion');
+ });
+});
diff --git a/src/__tests__/LineItemRow.test.tsx b/src/__tests__/LineItemRow.test.tsx
new file mode 100644
index 0000000..67bfb10
--- /dev/null
+++ b/src/__tests__/LineItemRow.test.tsx
@@ -0,0 +1,168 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import LineItemRow from '@/components/LineItemRow';
+
+// Mock dependencies
+jest.mock('@/components/settings/AddressBookPicker', () => {
+ return function MockAddressBookPicker({ value, onChange, placeholder, ariaLabel }: any) {
+ return (
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ aria-label={ariaLabel}
+ />
+ );
+ };
+});
+
+jest.mock('@/components/ui/Avatar', () => {
+ return function MockAvatar({ address }: any) {
+ return
{address}
;
+ };
+});
+
+jest.mock('@/components/EmailField', () => {
+ return function MockEmailField({ email, onEmailChange }: any) {
+ return (
+ onEmailChange(e.target.value)}
+ placeholder="email@example.com"
+ />
+ );
+ };
+});
+
+describe('LineItemRow', () => {
+ const mockProps = {
+ index: 0,
+ address: 'GTEST123',
+ amount: '100',
+ label: 'Test User',
+ email: 'test@example.com',
+ equalSplit: false,
+ amountOverride: undefined,
+ amountSuggestions: [],
+ activeField: null as const,
+ activeIndex: null,
+ canRemove: true,
+ emailByAddress: { GTEST123: 'test@example.com' },
+ onAddressChange: jest.fn(),
+ onAmountChange: jest.fn(),
+ onAmountFocus: jest.fn(),
+ onAmountSuggestionSelect: jest.fn(),
+ onRemove: jest.fn(),
+ onEmailChange: jest.fn(),
+ };
+
+ it('should render address input', () => {
+ render();
+ const addressInput = screen.getByDisplayValue('GTEST123');
+ expect(addressInput).toBeInTheDocument();
+ });
+
+ it('should render amount input', () => {
+ render();
+ const amountInput = screen.getByDisplayValue('100');
+ expect(amountInput).toBeInTheDocument();
+ });
+
+ it('should render email input', () => {
+ render();
+ const emailInput = screen.getByDisplayValue('test@example.com');
+ expect(emailInput).toBeInTheDocument();
+ });
+
+ it('should render avatar with address', () => {
+ render();
+ expect(screen.getByTestId('avatar')).toHaveTextContent('GTEST123');
+ });
+
+ it('should render remove button when canRemove is true', () => {
+ render();
+ const removeButton = screen.getByRole('button', { name: /remove recipient 1/i });
+ expect(removeButton).toBeInTheDocument();
+ });
+
+ it('should not render remove button when canRemove is false', () => {
+ render();
+ const removeButton = screen.queryByRole('button', { name: /remove recipient 1/i });
+ expect(removeButton).not.toBeInTheDocument();
+ });
+
+ it('should call onRemove when remove button is clicked', () => {
+ const { onRemove } = mockProps;
+ render();
+ const removeButton = screen.getByRole('button', { name: /remove recipient 1/i });
+ fireEvent.click(removeButton);
+ expect(onRemove).toHaveBeenCalled();
+ });
+
+ it('should call onAddressChange when address changes', () => {
+ const { onAddressChange } = mockProps;
+ render();
+ const addressInput = screen.getByDisplayValue('GTEST123');
+ fireEvent.change(addressInput, { target: { value: 'GNEWADDRESS' } });
+ expect(onAddressChange).toHaveBeenCalledWith('GNEWADDRESS', mockProps.email, mockProps.email);
+ });
+
+ it('should call onAmountChange when amount changes', () => {
+ const { onAmountChange } = mockProps;
+ render();
+ const amountInput = screen.getByDisplayValue('100');
+ fireEvent.change(amountInput, { target: { value: '200' } });
+ expect(onAmountChange).toHaveBeenCalledWith('200');
+ });
+
+ it('should call onAmountFocus when amount input is focused', () => {
+ const { onAmountFocus } = mockProps;
+ render();
+ const amountInput = screen.getByDisplayValue('100');
+ fireEvent.focus(amountInput);
+ expect(onAmountFocus).toHaveBeenCalled();
+ });
+
+ it('should call onEmailChange when email changes', () => {
+ const { onEmailChange } = mockProps;
+ render();
+ const emailInput = screen.getByDisplayValue('test@example.com');
+ fireEvent.change(emailInput, { target: { value: 'newemail@example.com' } });
+ expect(onEmailChange).toHaveBeenCalledWith('newemail@example.com');
+ });
+
+ it('should disable amount input when equalSplit is true', () => {
+ render();
+ const amountInput = screen.getByDisplayValue('100');
+ expect((amountInput as HTMLInputElement).readOnly).toBe(true);
+ });
+
+ it('should show amount suggestions when available', () => {
+ render(
+
+ );
+ expect(screen.getByText('50 USDC')).toBeInTheDocument();
+ expect(screen.getByText('150 USDC')).toBeInTheDocument();
+ });
+
+ it('should call onAmountSuggestionSelect when suggestion is clicked', () => {
+ const { onAmountSuggestionSelect } = mockProps;
+ render(
+
+ );
+ const suggestionButton = screen.getByText('50 USDC');
+ fireEvent.mouseDown(suggestionButton);
+ expect(onAmountSuggestionSelect).toHaveBeenCalledWith('50');
+ });
+});
diff --git a/src/components/InvoiceExportButton.tsx b/src/components/InvoiceExportButton.tsx
index f267746..c6349e4 100644
--- a/src/components/InvoiceExportButton.tsx
+++ b/src/components/InvoiceExportButton.tsx
@@ -195,9 +195,36 @@ export default function InvoiceExportButton({ invoice, total, branding }: Props)
type="button"
onClick={handleExport}
disabled={loading}
- className="px-3 py-1.5 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm font-semibold transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
+ className="px-3 py-1.5 rounded-lg bg-gray-700 hover:bg-gray-600 text-sm font-semibold transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 inline-flex items-center gap-2"
>
- {loading ? 'Generating…' : '↓ Export PDF'}
+ {loading ? (
+ <>
+
+ Generating…
+ >
+ ) : (
+ <>↓ Export PDF>
+ )}
);
}
diff --git a/src/components/InvoiceTimeline.tsx b/src/components/InvoiceTimeline.tsx
index c37246e..ca8f4ba 100644
--- a/src/components/InvoiceTimeline.tsx
+++ b/src/components/InvoiceTimeline.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useEffect, useState, useCallback } from 'react';
+import { useEffect, useState, useCallback, useRef } from 'react';
import WalletAddress from './WalletAddress';
import RelativeTime from '@/components/ui/RelativeTime';
@@ -63,6 +63,8 @@ export default function InvoiceTimeline({ invoiceId }: Props) {
const [loading, setLoading] = useState(true);
const [nextCursor, setNextCursor] = useState();
const [loadingMore, setLoadingMore] = useState(false);
+ const [expandedIndices, setExpandedIndices] = useState>(new Set());
+ const detailsRefs = useRef<(HTMLDivElement | null)[]>([]);
const load = useCallback(async () => {
setLoading(true);
@@ -83,6 +85,18 @@ export default function InvoiceTimeline({ invoiceId }: Props) {
setLoadingMore(false);
};
+ const toggleExpand = (index: number) => {
+ setExpandedIndices((prev) => {
+ const next = new Set(prev);
+ if (next.has(index)) {
+ next.delete(index);
+ } else {
+ next.add(index);
+ }
+ return next;
+ });
+ };
+
if (loading) {
return (
@@ -109,9 +123,35 @@ export default function InvoiceTimeline({ invoiceId }: Props) {
return (
+
{events.map((evt, i) => {
const meta = EVENT_META[evt.type] ?? EVENT_META.Created;
+ const isExpanded = expandedIndices.has(i);
+ const hasDetails = evt.actor || evt.txHash;
return (
-
{/* dot */}
@@ -126,21 +166,41 @@ export default function InvoiceTimeline({ invoiceId }: Props) {
{evt.description}
- {evt.actor && (
-
-
-
- )}
- {evt.txHash && (
-
toggleExpand(i)}
+ className="mt-1.5 inline-flex items-center gap-1 text-xs text-indigo-400 hover:text-indigo-300 transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-indigo-400"
+ aria-expanded={isExpanded}
>
- {evt.txHash.slice(0, 8)}…{evt.txHash.slice(-6)} ↗
-
+
{isExpanded ? '▼' : '▶'}
+ {isExpanded ? 'Hide details' : 'Show details'}
+
)}
+
+
);
})}
diff --git a/src/components/LineItemRow.tsx b/src/components/LineItemRow.tsx
new file mode 100644
index 0000000..d363b08
--- /dev/null
+++ b/src/components/LineItemRow.tsx
@@ -0,0 +1,129 @@
+'use client';
+
+import AddressBookPicker from '@/components/settings/AddressBookPicker';
+import Avatar from '@/components/ui/Avatar';
+import EmailField from '@/components/EmailField';
+
+export interface LineItemRowProps {
+ index: number;
+ address: string;
+ amount: string;
+ label?: string;
+ email?: string;
+ equalSplit?: boolean;
+ amountOverride?: string;
+ amountSuggestions?: string[];
+ activeField?: 'address' | 'amount' | null;
+ activeIndex?: number | null;
+ canRemove: boolean;
+ emailByAddress?: Record
;
+ onAddressChange: (address: string, label?: string, email?: string) => void;
+ onAmountChange: (amount: string) => void;
+ onAmountFocus: () => void;
+ onAmountSuggestionSelect: (amount: string) => void;
+ onRemove: () => void;
+ onEmailChange: (email: string) => void;
+}
+
+export default function LineItemRow({
+ index,
+ address,
+ amount,
+ label,
+ email,
+ equalSplit = false,
+ amountOverride,
+ amountSuggestions = [],
+ activeField = null,
+ activeIndex = null,
+ canRemove,
+ emailByAddress = {},
+ onAddressChange,
+ onAmountChange,
+ onAmountFocus,
+ onAmountSuggestionSelect,
+ onRemove,
+ onEmailChange,
+}: LineItemRowProps) {
+ return (
+
+
+
+
+
+
onAddressChange(addr, lbl, email)}
+ placeholder="G... or name*domain.com address"
+ ariaLabel={`Recipient ${index + 1} address`}
+ />
+
+
+
+
onAmountChange(e.target.value)
+ }
+ onFocus={() => !equalSplit && onAmountFocus()}
+ readOnly={equalSplit}
+ required
+ aria-label={`Recipient ${index + 1} amount`}
+ className={`w-full bg-gray-800 border rounded-lg px-3 py-2 min-h-11 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${
+ equalSplit
+ ? 'border-gray-600 text-gray-400 cursor-not-allowed'
+ : 'border-gray-700'
+ }`}
+ />
+ {activeField === 'amount' && activeIndex === index && amountSuggestions.length > 0 && !equalSplit && (
+
+ {amountSuggestions.map((amt) => (
+ -
+
+
+ ))}
+
+ )}
+
+
+ {canRemove && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/RecipientForm.tsx b/src/components/RecipientForm.tsx
index 8d4d8d1..276c05b 100644
--- a/src/components/RecipientForm.tsx
+++ b/src/components/RecipientForm.tsx
@@ -1,15 +1,13 @@
"use client";
import { useState, useRef, useMemo, useEffect } from "react";
-import AddressBookPicker from "@/components/settings/AddressBookPicker";
import { searchEntries, addEntry, getEmailForAddress, type AddressEntry } from "@/lib/addressBook";
-import Avatar from "@/components/ui/Avatar";
import { searchAddressHistory, searchAmountHistory } from "@/lib/invoiceHistory";
import { searchRecipients, touchRecipient, type RecipientEntry } from "@/lib/recipients";
import { truncateAddress } from "@stellar-split/sdk";
import CsvRecipientImport from "@/components/CsvRecipientImport";
import { useEmailValidation } from "@/hooks/useEmailValidation";
-import EmailField from "@/components/EmailField";
+import LineItemRow from "@/components/LineItemRow";
export interface RecipientRow {
address: string;
@@ -171,85 +169,27 @@ export default function RecipientForm({
return (
{recipients.map((row, i) => (
-
-
-
-
-
-
updateRow(i, address, label, row.email)}
- placeholder="G... or name*domain.com address"
- ariaLabel={`Recipient ${i + 1} address`}
- />
-
-
-
-
handleAmountChange(i, e.target.value)
- }
- onFocus={() => !equalSplit && handleAmountFocus(i)}
- readOnly={equalSplit}
- required
- aria-label={`Recipient ${i + 1} amount`}
- className={`w-full bg-gray-800 border rounded-lg px-3 py-2 min-h-11 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${
- equalSplit
- ? "border-gray-600 text-gray-400 cursor-not-allowed"
- : "border-gray-700"
- }`}
- />
- {activeField === "amount" && activeIndex === i && amountSuggestions.length > 0 && !equalSplit && (
-
- {amountSuggestions.map((amount) => (
- -
-
-
- ))}
-
- )}
-
-
- {recipients.length > 1 && (
-
- )}
-
-
-
-
-
- updateRow(i, row.address, row.label, email)}
- onBlur={() => {}}
- />
-
-
-
+
1}
+ emailByAddress={emailByAddress}
+ onAddressChange={(address, label, email) => updateRow(i, address, label, email)}
+ onAmountChange={(amount) => handleAmountChange(i, amount)}
+ onAmountFocus={() => handleAmountFocus(i)}
+ onAmountSuggestionSelect={(amount) => selectAmountSuggestion(i, amount)}
+ onRemove={() => removeRow(i)}
+ onEmailChange={(email) => updateRow(i, row.address, row.label, email)}
+ />
))}