diff --git a/src/__tests__/InvoiceExportButton.test.tsx b/src/__tests__/InvoiceExportButton.test.tsx new file mode 100644 index 0000000..2c86ebf --- /dev/null +++ b/src/__tests__/InvoiceExportButton.test.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { vi, describe, test, expect, beforeEach } from 'vitest'; +import InvoiceExportButton from '@/components/InvoiceExportButton'; +import type { Invoice } from '@stellar-split/sdk'; + +const mockInvoice: Invoice = { + id: 'test-invoice-123', + creator: 'GBBD47UZQ5DSJYNF4H46ZCT5GX6MCLJJWHCCTV4GMSD5SJWATLVL547Z', + status: 'PENDING', + token: 'USDC', + recipients: [ + { address: 'GTEST1', amount: BigInt(1000000) }, + { address: 'GTEST2', amount: BigInt(2000000) }, + ], + payments: [], + funded: BigInt(0), + deadline: 0, +}; + +// Mock the toast hook +vi.mock('@/hooks/useToast', () => ({ + useToast: () => ({ + addToast: vi.fn(), + }), +})); + +describe('InvoiceExportButton', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('renders export button with initial text', () => { + render(); + + expect(screen.getByRole('button')).toHaveTextContent('↓ Export PDF'); + }); + + test('button is not disabled initially', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).not.toBeDisabled(); + }); + + test('applies correct styling to button', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('px-3', 'py-1.5', 'rounded-lg', 'bg-gray-700'); + }); + + test('button has focus-visible ring on focus', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('focus-visible:ring-2', 'focus-visible:ring-indigo-500'); + }); + + test('maintains disabled state opacity styling', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('disabled:opacity-50'); + }); + + test('button renders with proper button type', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('type', 'button'); + }); + + test('button contains spinner icon', () => { + render(); + + const svg = screen.getByRole('button').querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + test('has aria-hidden attribute on spinner icon', () => { + render(); + + const svg = screen.getByRole('button').querySelector('svg'); + expect(svg).toHaveAttribute('aria-hidden', 'true'); + }); + + test('renders with flex layout for content', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('flex', 'items-center', 'gap-2'); + }); + + test('applies hover styling classes', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('hover:bg-gray-600'); + }); + + test('applies text styling for small font', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('text-sm', 'font-semibold'); + }); + + test('applies transition-colors for smooth state changes', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('transition-colors'); + }); +}); diff --git a/src/__tests__/InvoiceListSentinel.test.tsx b/src/__tests__/InvoiceListSentinel.test.tsx new file mode 100644 index 0000000..6509705 --- /dev/null +++ b/src/__tests__/InvoiceListSentinel.test.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { vi, describe, test, expect, beforeEach, afterEach } from 'vitest'; +import InvoiceListSentinel from '@/components/InvoiceListSentinel'; + +describe('InvoiceListSentinel', () => { + let mockObserverDisconnect: ReturnType; + let mockObserverObserve: ReturnType; + + beforeEach(() => { + mockObserverDisconnect = vi.fn(); + mockObserverObserve = vi.fn(); + + global.IntersectionObserver = vi.fn((callback) => ({ + observe: mockObserverObserve, + disconnect: mockObserverDisconnect, + unobserve: vi.fn(), + })) as any; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test('calls observer.disconnect() on unmount', () => { + const { unmount } = render( + + ); + + unmount(); + + expect(mockObserverDisconnect).toHaveBeenCalledTimes(1); + }); + + test('observes the sentinel element on mount', () => { + render(); + + expect(mockObserverObserve).toHaveBeenCalledTimes(1); + }); + + test('displays loading spinner when loading is true', () => { + render(); + + expect(screen.getByText('Loading more invoices…')).toBeInTheDocument(); + }); + + test('displays all loaded message when allLoaded is true', () => { + render(); + + expect(screen.getByText('All invoices loaded')).toBeInTheDocument(); + }); + + test('does not display loading spinner or all loaded message when loading is false and allLoaded is false', () => { + render(); + + expect(screen.queryByText('Loading more invoices…')).not.toBeInTheDocument(); + expect(screen.queryByText('All invoices loaded')).not.toBeInTheDocument(); + }); + + test('calls onVisible when sentinel enters viewport', () => { + const onVisible = vi.fn(); + const mockCallback = vi.fn(); + + global.IntersectionObserver = vi.fn((callback) => { + // Simulate intersection + callback([{ isIntersecting: true } as IntersectionObserverEntry]); + return { + observe: vi.fn(), + disconnect: vi.fn(), + unobserve: vi.fn(), + }; + }) as any; + + render(); + + expect(onVisible).toHaveBeenCalled(); + }); + + test('does not call onVisible when sentinel is not intersecting', () => { + const onVisible = vi.fn(); + + global.IntersectionObserver = vi.fn((callback) => { + // Simulate no intersection + callback([{ isIntersecting: false } as IntersectionObserverEntry]); + return { + observe: vi.fn(), + disconnect: vi.fn(), + unobserve: vi.fn(), + }; + }) as any; + + render(); + + expect(onVisible).not.toHaveBeenCalled(); + }); + + test('uses default rootMargin of 300px when not specified', () => { + const IntersectionObserverSpy = vi.fn((callback) => ({ + observe: vi.fn(), + disconnect: vi.fn(), + unobserve: vi.fn(), + })); + + global.IntersectionObserver = IntersectionObserverSpy as any; + + render(); + + expect(IntersectionObserverSpy).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ rootMargin: '300px' }) + ); + }); + + test('uses custom rootMargin when specified', () => { + const IntersectionObserverSpy = vi.fn((callback) => ({ + observe: vi.fn(), + disconnect: vi.fn(), + unobserve: vi.fn(), + })); + + global.IntersectionObserver = IntersectionObserverSpy as any; + + render( + + ); + + expect(IntersectionObserverSpy).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ rootMargin: '500px' }) + ); + }); + + test('has aria-live attribute for accessibility', () => { + const { container } = render( + + ); + + const sentinel = container.querySelector('[aria-live="polite"]'); + expect(sentinel).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/InvoiceTimeline.test.tsx b/src/__tests__/InvoiceTimeline.test.tsx new file mode 100644 index 0000000..8bfb247 --- /dev/null +++ b/src/__tests__/InvoiceTimeline.test.tsx @@ -0,0 +1,381 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, describe, test, expect, beforeEach } from 'vitest'; +import InvoiceTimeline, { type InvoiceEvent } from '@/components/InvoiceTimeline'; + +// Mock RelativeTime component +vi.mock('@/components/ui/RelativeTime', () => ({ + default: ({ iso, className }: any) => {iso}, +})); + +// Mock WalletAddress component +vi.mock('@/components/WalletAddress', () => ({ + default: ({ address }: any) => {address}, +})); + +const mockEvents: InvoiceEvent[] = [ + { + type: 'Created', + description: 'Invoice created', + timestamp: Math.floor(Date.now() / 1000), + }, + { + type: 'PaymentReceived', + description: 'Payment received from payer', + actor: 'GTEST_PAYER', + timestamp: Math.floor(Date.now() / 1000) - 3600, + }, + { + type: 'Funded', + description: 'Invoice fully funded', + txHash: 'abc123def456789', + timestamp: Math.floor(Date.now() / 1000) - 7200, + }, +]; + +describe('InvoiceTimeline', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('displays loading state initially', () => { + vi.doMock('@/lib/stellar', () => ({ + splitClient: { + getInvoiceEvents: vi.fn(() => new Promise(() => {})), // Never resolves + }, + })); + + render(); + + // Should show loading skeleton + expect(screen.getByText(/Loading/i, { exact: false })).toBeInTheDocument(); + }); + + test('displays "No events yet" message when events are empty', async () => { + vi.doMock('@/lib/stellar', () => ({ + splitClient: { + getInvoiceEvents: vi.fn().mockResolvedValue({ events: [] }), + }, + })); + + render(); + + await waitFor(() => { + expect(screen.getByText('No events yet.')).toBeInTheDocument(); + }); + }); + + test('renders timeline events correctly', async () => { + vi.doMock('@/lib/stellar', () => ({ + splitClient: { + getInvoiceEvents: vi.fn().mockResolvedValue({ events: mockEvents }), + }, + })); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Created/i)).toBeInTheDocument(); + expect(screen.getByText(/Payment Received/i)).toBeInTheDocument(); + expect(screen.getByText(/Funded/i)).toBeInTheDocument(); + }); + }); + + test('displays event descriptions', async () => { + vi.doMock('@/lib/stellar', () => ({ + splitClient: { + getInvoiceEvents: vi.fn().mockResolvedValue({ events: mockEvents }), + }, + })); + + render(); + + await waitFor(() => { + expect(screen.getByText('Invoice created')).toBeInTheDocument(); + expect(screen.getByText('Payment received from payer')).toBeInTheDocument(); + expect(screen.getByText('Invoice fully funded')).toBeInTheDocument(); + }); + }); + + test('expands and collapses details when event has actor or txHash', async () => { + const user = userEvent.setup(); + + vi.doMock('@/lib/stellar', () => ({ + splitClient: { + getInvoiceEvents: vi.fn().mockResolvedValue({ events: mockEvents }), + }, + })); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Payment Received/i)).toBeInTheDocument(); + }); + + // Find and click the expand button for the PaymentReceived event + const buttons = screen.getAllByRole('button'); + const expandButton = buttons.find(btn => btn.textContent?.includes('Payment Received')); + + expect(expandButton).toBeInTheDocument(); + + // Click to expand + await user.click(expandButton!); + + // Actor details should be visible + await waitFor(() => { + expect(screen.getByText('GTEST_PAYER')).toBeInTheDocument(); + }); + }); + + test('does not show expand button for events without actor or txHash', async () => { + const eventsNoDetails: InvoiceEvent[] = [ + { + type: 'Created', + description: 'Invoice created', + timestamp: Math.floor(Date.now() / 1000), + }, + ]; + + vi.doMock('@/lib/stellar', () => ({ + splitClient: { + getInvoiceEvents: vi.fn().mockResolvedValue({ events: eventsNoDetails }), + }, + })); + + render(); + + await waitFor(() => { + const button = screen.getByRole('button', { name: /Created/i }); + expect(button).toBeDisabled(); + }); + }); + + test('displays actor information when expanded', async () => { + const user = userEvent.setup(); + + vi.doMock('@/lib/stellar', () => ({ + splitClient: { + getInvoiceEvents: vi.fn().mockResolvedValue({ events: mockEvents }), + }, + })); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Payment Received/i)).toBeInTheDocument(); + }); + + const buttons = screen.getAllByRole('button'); + const expandButton = buttons.find(btn => btn.textContent?.includes('Payment Received')); + + await user.click(expandButton!); + + await waitFor(() => { + expect(screen.getByText('GTEST_PAYER')).toBeInTheDocument(); + }); + }); + + test('displays transaction hash link when available', async () => { + const user = userEvent.setup(); + + vi.doMock('@/lib/stellar', () => ({ + splitClient: { + getInvoiceEvents: vi.fn().mockResolvedValue({ events: mockEvents }), + }, + })); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Funded/i)).toBeInTheDocument(); + }); + + const buttons = screen.getAllByRole('button'); + const expandButton = buttons.find(btn => btn.textContent?.includes('Funded')); + + await user.click(expandButton!); + + await waitFor(() => { + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', expect.stringContaining('abc123')); + expect(link).toHaveAttribute('target', '_blank'); + }); + }); + + test('shows chevron icon that rotates when expanded', async () => { + const user = userEvent.setup(); + + vi.doMock('@/lib/stellar', () => ({ + splitClient: { + getInvoiceEvents: vi.fn().mockResolvedValue({ events: mockEvents }), + }, + })); + + const { container } = render(); + + await waitFor(() => { + expect(screen.getByText(/Payment Received/i)).toBeInTheDocument(); + }); + + const buttons = screen.getAllByRole('button'); + const expandButton = buttons.find(btn => btn.textContent?.includes('Payment Received')); + + // Initially collapsed (0deg) + let chevron = expandButton?.querySelector('span'); + expect(chevron?.style.transform).toMatch(/rotate\(0deg\)/); + + // Click to expand + await user.click(expandButton!); + + // Now it should be rotated + await waitFor(() => { + chevron = expandButton?.querySelector('span'); + expect(chevron?.style.transform).toMatch(/rotate\(180deg\)/); + }); + }); + + test('animates expand/collapse with max-height transition', async () => { + const user = userEvent.setup(); + + vi.doMock('@/lib/stellar', () => ({ + splitClient: { + getInvoiceEvents: vi.fn().mockResolvedValue({ events: mockEvents }), + }, + })); + + const { container } = render(); + + await waitFor(() => { + expect(screen.getByText(/Payment Received/i)).toBeInTheDocument(); + }); + + const details = container.querySelector('.timeline-entry-details'); + expect(details).toHaveStyle('transition: max-height 300ms cubic-bezier(0.4, 0, 0.2, 1)'); + }); + + test('includes inline styles for animation that respect prefers-reduced-motion', async () => { + vi.doMock('@/lib/stellar', () => ({ + splitClient: { + getInvoiceEvents: vi.fn().mockResolvedValue({ events: mockEvents }), + }, + })); + + const { container } = render(); + + await waitFor(() => { + const style = container.querySelector('style'); + expect(style?.textContent).toContain('prefers-reduced-motion'); + }); + }); + + test('displays event type with proper formatting', async () => { + vi.doMock('@/lib/stellar', () => ({ + splitClient: { + getInvoiceEvents: vi.fn().mockResolvedValue({ + events: [ + { + type: 'DisputeRaised', + description: 'Dispute was raised', + timestamp: Math.floor(Date.now() / 1000), + }, + ], + }), + }, + })); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Dispute Raised/i)).toBeInTheDocument(); + }); + }); + + test('displays event icons', async () => { + vi.doMock('@/lib/stellar', () => ({ + splitClient: { + getInvoiceEvents: vi.fn().mockResolvedValue({ + events: [ + { + type: 'Funded', + description: 'Fully funded', + timestamp: Math.floor(Date.now() / 1000), + }, + ], + }), + }, + })); + + render(); + + await waitFor(() => { + expect(screen.getByText(/✅/)).toBeInTheDocument(); // Funded icon + }); + }); + + test('shows "Load older events" button when nextCursor is available', async () => { + vi.doMock('@/lib/stellar', () => ({ + splitClient: { + getInvoiceEvents: vi + .fn() + .mockResolvedValue({ events: mockEvents, nextCursor: 'cursor123' }), + }, + })); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Load older events/i })).toBeInTheDocument(); + }); + }); + + test('does not show "Load older events" button when there are no more pages', async () => { + vi.doMock('@/lib/stellar', () => ({ + splitClient: { + getInvoiceEvents: vi.fn().mockResolvedValue({ events: mockEvents }), + }, + })); + + render(); + + await waitFor(() => { + expect( + screen.queryByRole('button', { name: /Load older events/i }) + ).not.toBeInTheDocument(); + }); + }); + + test('loads older events when "Load older events" button is clicked', async () => { + const user = userEvent.setup(); + + vi.doMock('@/lib/stellar', () => ({ + splitClient: { + getInvoiceEvents: vi + .fn() + .mockResolvedValueOnce({ events: mockEvents, nextCursor: 'cursor123' }) + .mockResolvedValueOnce({ + events: [ + { + type: 'Archived', + description: 'Invoice archived', + timestamp: Math.floor(Date.now() / 1000) - 86400, + }, + ], + }), + }, + })); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Load older events/i })).toBeInTheDocument(); + }); + + const loadButton = screen.getByRole('button', { name: /Load older events/i }); + await user.click(loadButton); + + await waitFor(() => { + expect(screen.getByText('Invoice archived')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/LineItemRow.test.tsx b/src/__tests__/LineItemRow.test.tsx new file mode 100644 index 0000000..892a05e --- /dev/null +++ b/src/__tests__/LineItemRow.test.tsx @@ -0,0 +1,292 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, describe, test, expect } from 'vitest'; +import LineItemRow from '@/components/LineItemRow'; + +// Mock AmountDisplay to avoid complex dependencies +vi.mock('@/components/invoice/AmountDisplay', () => ({ + default: ({ amount, inline }: any) => {amount.toString()}, +})); + +describe('LineItemRow', () => { + const testAddress = 'GBBD47UZQ5DSJYNF4H46ZCT5GX6MCLJJWHCCTV4GMSD5SJWATLVL547Z'; + const testAmount = BigInt(1000000); + + test('renders payment line item with correct data', () => { + render( + + + + + + ); + + expect(screen.getByTitle(testAddress)).toBeInTheDocument(); + expect(screen.getByText(testAmount.toString())).toBeInTheDocument(); + }); + + test('renders recipient line item with correct data', () => { + render( + + + + + + ); + + expect(screen.getByTitle(testAddress)).toBeInTheDocument(); + expect(screen.getByText(testAmount.toString())).toBeInTheDocument(); + }); + + test('truncates address correctly', () => { + render( + + + + + + ); + + const addressElement = screen.getByTitle(testAddress); + expect(addressElement).toBeInTheDocument(); + // The element should have max-w-[200px] truncate class + expect(addressElement).toHaveClass('truncate', 'max-w-[200px]'); + }); + + test('displays edit button when onEdit handler is provided', () => { + const onEdit = vi.fn(); + + render( + + + + + + ); + + expect(screen.getByRole('button', { name: /Edit payment/i })).toBeInTheDocument(); + }); + + test('displays delete button when onDelete handler is provided', () => { + const onDelete = vi.fn(); + + render( + + + + + + ); + + expect(screen.getByRole('button', { name: /Delete payment/i })).toBeInTheDocument(); + }); + + test('does not display action buttons when no handlers are provided', () => { + render( + + + + + + ); + + expect(screen.queryByText('Edit')).not.toBeInTheDocument(); + expect(screen.queryByText('Delete')).not.toBeInTheDocument(); + }); + + test('calls onEdit with correct arguments when edit button is clicked', async () => { + const user = userEvent.setup(); + const onEdit = vi.fn(); + + render( + + + + + + ); + + const editButton = screen.getByRole('button', { name: /Edit payment/i }); + await user.click(editButton); + + expect(onEdit).toHaveBeenCalledWith(testAddress, testAmount); + }); + + test('calls onDelete with correct arguments when delete button is clicked', async () => { + const user = userEvent.setup(); + const onDelete = vi.fn(); + + render( + + + + + + ); + + const deleteButton = screen.getByRole('button', { name: /Delete payment/i }); + await user.click(deleteButton); + + expect(onDelete).toHaveBeenCalledWith(testAddress); + }); + + test('applies dragging opacity when isDragging is true', () => { + const { container } = render( + + + + + + ); + + const row = container.querySelector('tr'); + expect(row).toHaveClass('opacity-50'); + }); + + test('does not apply dragging opacity when isDragging is false', () => { + const { container } = render( + + + + + + ); + + const row = container.querySelector('tr'); + expect(row).not.toHaveClass('opacity-50'); + }); + + test('applies cursor-move class when dragging is enabled', () => { + const { container } = render( + + + + + + ); + + const row = container.querySelector('tr'); + expect(row).toHaveClass('cursor-move'); + }); + + test('makes row draggable when drag handlers are provided', () => { + const { container } = render( + + + + + + ); + + const row = container.querySelector('tr'); + expect(row).toHaveAttribute('draggable', 'true'); + }); + + test('is not draggable when no drag handlers are provided', () => { + const { container } = render( + + + + + + ); + + const row = container.querySelector('tr'); + expect(row).toHaveAttribute('draggable', 'false'); + }); + + test('calls onDragStart when drag starts', async () => { + const onDragStart = vi.fn(); + + const { container } = render( + + + + + + ); + + const row = container.querySelector('tr') as HTMLElement; + const dragEvent = new DragEvent('dragstart', { bubbles: true }); + row.dispatchEvent(dragEvent); + + expect(onDragStart).toHaveBeenCalled(); + }); + + test('applies correct styling to address cell', () => { + const { container } = render( + + + + + + ); + + const addressCell = container.querySelector('td'); + expect(addressCell).toHaveClass('font-mono', 'text-gray-300', 'truncate'); + }); + + test('applies correct styling to amount cell', () => { + const { container } = render( + + + + + + ); + + const cells = container.querySelectorAll('td'); + const amountCell = cells[1]; + expect(amountCell).toHaveClass('text-right', 'text-indigo-300'); + }); + + test('renders different aria-labels for payment and recipient types', () => { + const onEdit = vi.fn(); + + const { rerender } = render( + + + + + + ); + + expect(screen.getByRole('button', { name: /Edit payment/i })).toBeInTheDocument(); + + rerender( + + + + + + ); + + expect(screen.getByRole('button', { name: /Edit recipient/i })).toBeInTheDocument(); + }); +}); diff --git a/src/app/invoice/[id]/page.tsx b/src/app/invoice/[id]/page.tsx index 501ed5d..1bd8312 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -90,6 +90,7 @@ import { useInvoiceRole } from "@/hooks/useInvoiceRole"; import CursorOverlay from "@/components/CursorOverlay"; import ReconnectionBanner from "@/components/ReconnectionBanner"; import SplitSummaryCard from "@/components/invoice/SplitSummaryCard"; +import LineItemRow from "@/components/LineItemRow"; const RecipientPieChart = dynamic(() => import("@/components/RecipientPieChart"), { ssr: false }); const InvoiceQR = dynamic(() => import("@/components/InvoiceQR"), { ssr: false }); @@ -763,14 +764,12 @@ export default function InvoiceDetailPage({ params }: Props) { {invoice.payments.map((p, i) => ( - - - {truncateAddress(p.payer)} - - - - - + ))} diff --git a/src/components/InvoiceExportButton.tsx b/src/components/InvoiceExportButton.tsx index f267746..f90d4c0 100644 --- a/src/components/InvoiceExportButton.tsx +++ b/src/components/InvoiceExportButton.tsx @@ -4,6 +4,7 @@ import { useCallback, useState } from 'react'; import type { Invoice } from '@stellar-split/sdk'; import { formatAmount } from '@stellar-split/sdk'; import { DEFAULT_ACCENT_COLOR, type BrandSettings } from '@/lib/brandSettings'; +import { useToast } from '@/hooks/useToast'; interface Props { invoice: Invoice; @@ -40,16 +41,13 @@ async function fetchLogoDataUrl(url: string): Promise { export default function InvoiceExportButton({ invoice, total, branding }: Props) { const [loading, setLoading] = useState(false); + const { addToast } = useToast(); const handleExport = useCallback(async () => { setLoading(true); try { - // Lazy-load @react-pdf/renderer so it doesn't bloat the initial bundle const { pdf, Document, Page, Text, View, StyleSheet, Image } = await import('@react-pdf/renderer'); - // Resolve branding: prefer the prop handed down from the invoice page, - // but fall back to fetching the creator's settings so exports triggered - // elsewhere stay branded. let brand = branding ?? null; if (!brand) { try { @@ -185,19 +183,54 @@ export default function InvoiceExportButton({ invoice, total, branding }: Props) a.download = `invoice-${invoice.id}.pdf`; a.click(); URL.revokeObjectURL(url); + } catch (error) { + addToast({ + type: 'error', + message: 'Failed to export invoice. Please try again.', + }); + console.error('Export error:', error); } finally { setLoading(false); } - }, [invoice, total, branding]); + }, [invoice, total, branding, addToast]); return ( - {loading ? 'Generating…' : '↓ Export PDF'} + {loading ? ( + <> + + + + + Generating… + > + ) : ( + <> + ↓ Export PDF + > + )} ); } diff --git a/src/components/InvoiceListSentinel.tsx b/src/components/InvoiceListSentinel.tsx index 28449a5..737df2e 100644 --- a/src/components/InvoiceListSentinel.tsx +++ b/src/components/InvoiceListSentinel.tsx @@ -45,7 +45,10 @@ export default function InvoiceListSentinel({ ); observer.observe(el); - return () => observer.disconnect(); + + return () => { + observer.disconnect(); + }; }, [onVisible, rootMargin]); return ( diff --git a/src/components/InvoiceTimeline.tsx b/src/components/InvoiceTimeline.tsx index c37246e..c2abd7c 100644 --- a/src/components/InvoiceTimeline.tsx +++ b/src/components/InvoiceTimeline.tsx @@ -25,6 +25,26 @@ interface Props { invoiceId: string; } +const timelineStyles = ` + @media (prefers-reduced-motion: no-preference) { + .timeline-entry-details { + overflow: hidden; + transition: max-height 300ms cubic-bezier(0.4, 0, 0.2, 1); + } + .timeline-entry-details[data-expanded="false"] { + max-height: 0; + } + .timeline-entry-details[data-expanded="true"] { + max-height: 500px; + } + } + @media (prefers-reduced-motion) { + .timeline-entry-details { + overflow: visible; + } + } +`; + const STELLAR_EXPERT_BASE = process.env.NEXT_PUBLIC_STELLAR_NETWORK === 'mainnet' ? 'https://stellar.expert/explorer/public/tx' @@ -63,6 +83,7 @@ export default function InvoiceTimeline({ invoiceId }: Props) { const [loading, setLoading] = useState(true); const [nextCursor, setNextCursor] = useState(); const [loadingMore, setLoadingMore] = useState(false); + const [expandedEntries, setExpandedEntries] = useState>(new Set()); const load = useCallback(async () => { setLoading(true); @@ -83,6 +104,18 @@ export default function InvoiceTimeline({ invoiceId }: Props) { setLoadingMore(false); }; + const toggleExpanded = (index: number) => { + setExpandedEntries((prev) => { + const next = new Set(prev); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }; + if (loading) { return ( @@ -108,54 +141,84 @@ export default function InvoiceTimeline({ invoiceId }: Props) { } return ( - - - {events.map((evt, i) => { - const meta = EVENT_META[evt.type] ?? EVENT_META.Created; - return ( - - {/* dot */} - - - - {meta.icon} {evt.type.replace(/([A-Z])/g, ' $1').trim()} - - - - {evt.description} - {evt.actor && ( - - + <> + + + + {events.map((evt, i) => { + const meta = EVENT_META[evt.type] ?? EVENT_META.Created; + const isExpanded = expandedEntries.has(i); + const hasDetails = evt.actor || evt.txHash; + + return ( + + {/* dot */} + + + + hasDetails && toggleExpanded(i)} + className={`${hasDetails ? 'cursor-pointer hover:opacity-70' : ''} text-left font-medium text-sm ${meta.color} transition-opacity`} + disabled={!hasDetails} + > + {meta.icon} {evt.type.replace(/([A-Z])/g, ' $1').trim()} + {hasDetails && ( + + ▼ + + )} + + + - )} - {evt.txHash && ( - {evt.description} + + - {evt.txHash.slice(0, 8)}…{evt.txHash.slice(-6)} ↗ - - )} - - ); - })} - - - {nextCursor && ( - - {loadingMore ? 'Loading…' : 'Load older events'} - - )} - + + {evt.actor && ( + + + + )} + {evt.txHash && ( + + {evt.txHash.slice(0, 8)}…{evt.txHash.slice(-6)} ↗ + + )} + + + + ); + })} + + + {nextCursor && ( + + {loadingMore ? 'Loading…' : 'Load older events'} + + )} + + > ); } diff --git a/src/components/LineItemRow.tsx b/src/components/LineItemRow.tsx new file mode 100644 index 0000000..50b3548 --- /dev/null +++ b/src/components/LineItemRow.tsx @@ -0,0 +1,79 @@ +'use client'; + +import React from 'react'; +import type { Invoice } from '@stellar-split/sdk'; +import { truncateAddress } from '@stellar-split/sdk'; +import AmountDisplay from '@/components/invoice/AmountDisplay'; + +export interface LineItemRowProps { + type: 'payment' | 'recipient'; + address: string; + amount: bigint; + onEdit?: (address: string, amount: bigint) => void; + onDelete?: (address: string) => void; + isDragging?: boolean; + onDragStart?: (e: React.DragEvent) => void; + onDragOver?: (e: React.DragEvent) => void; + onDrop?: (e: React.DragEvent) => void; + onDragEnd?: (e: React.DragEvent) => void; +} + +export default function LineItemRow({ + type, + address, + amount, + onEdit, + onDelete, + isDragging = false, + onDragStart, + onDragOver, + onDrop, + onDragEnd, +}: LineItemRowProps) { + return ( + + + {truncateAddress(address)} + + + + + {(onEdit || onDelete) && ( + + + {onEdit && ( + onEdit(address, amount)} + className="text-xs text-indigo-400 hover:text-indigo-300" + aria-label={`Edit ${type} ${truncateAddress(address)}`} + > + Edit + + )} + {onDelete && ( + onDelete(address)} + className="text-xs text-red-400 hover:text-red-300" + aria-label={`Delete ${type} ${truncateAddress(address)}`} + > + Delete + + )} + + + )} + + ); +}
{evt.description}