Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7,491 changes: 3,816 additions & 3,675 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
},
"dependencies": {
"@creit.tech/stellar-wallets-kit": "^2.0.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@stellar/freighter-api": "^5.0.0",
"@stellar/stellar-sdk": "^14.1.1",
"buffer": "^6.0.3",
Expand All @@ -30,14 +33,21 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^9",
"eslint-config-next": "15.3.1",
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.8",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.7"
}
}
82 changes: 82 additions & 0 deletions src/components/invoices/InvoiceCancellationModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { InvoiceCancellationModal } from './InvoiceCancellationModal';

describe('InvoiceCancellationModal', () => {
it('renders modal when open is true', () => {
render(
<InvoiceCancellationModal
open={true}
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>
);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Cancel Invoice')).toBeInTheDocument();
expect(
screen.getByText('Are you sure you want to cancel this invoice? This cannot be undone.')
).toBeInTheDocument();
});

it('does not render modal when open is false', () => {
render(
<InvoiceCancellationModal
open={false}
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

it('calls onConfirm when confirm button is clicked', () => {
const handleConfirm = vi.fn();
const handleOpenChange = vi.fn();

render(
<InvoiceCancellationModal
open={true}
onOpenChange={handleOpenChange}
onConfirm={handleConfirm}
/>
);

const confirmButton = screen.getByRole('button', { name: /confirm cancellation/i });
fireEvent.click(confirmButton);

expect(handleConfirm).toHaveBeenCalledTimes(1);
expect(handleOpenChange).toHaveBeenCalledWith(false);
});

it('calls onOpenChange with false when cancel button is clicked', () => {
const handleOpenChange = vi.fn();

render(
<InvoiceCancellationModal
open={true}
onOpenChange={handleOpenChange}
onConfirm={vi.fn()}
/>
);

const cancelButton = screen.getByRole('button', { name: 'Cancel' });
fireEvent.click(cancelButton);

expect(handleOpenChange).toHaveBeenCalledWith(false);
});

it('calls onOpenChange with false when Escape key is pressed', () => {
const handleOpenChange = vi.fn();

render(
<InvoiceCancellationModal
open={true}
onOpenChange={handleOpenChange}
onConfirm={vi.fn()}
/>
);

fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape', code: 'Escape' });
expect(handleOpenChange).toHaveBeenCalledWith(false);
});
});
58 changes: 58 additions & 0 deletions src/components/invoices/InvoiceCancellationModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
'use client';

import * as React from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { X, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';

export interface InvoiceCancellationModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}

export function InvoiceCancellationModal({
open,
onOpenChange,
onConfirm,
}: InvoiceCancellationModalProps) {
const handleConfirm = () => {
onConfirm();
onOpenChange(false);
};

return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<Dialog.Content className="fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg">
<div className="flex flex-col space-y-2 text-center sm:text-left">
<Dialog.Title className="flex items-center gap-2 text-lg font-semibold text-slate-900">
<AlertTriangle className="h-5 w-5 text-red-600" />
Cancel Invoice
</Dialog.Title>
<Dialog.Description className="text-sm text-slate-500">
Are you sure you want to cancel this invoice? This cannot be undone.
</Dialog.Description>
</div>

<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-4">
<Dialog.Close asChild>
<Button variant="outline" className="mt-2 sm:mt-0">
Cancel
</Button>
</Dialog.Close>
<Button variant="destructive" onClick={handleConfirm}>
Confirm Cancellation
</Button>
</div>

<Dialog.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 data-[state=open]:text-slate-500">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
56 changes: 56 additions & 0 deletions src/components/invoices/InvoiceRowActions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { InvoiceRowActions } from './InvoiceRowActions';

describe('InvoiceRowActions', () => {
const mockInvoiceId = 'inv_123';

it('renders the dropdown trigger button', () => {
render(
<InvoiceRowActions
invoiceId={mockInvoiceId}
onViewDetails={vi.fn()}
onCopyPaymentLink={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: /invoice actions/i })).toBeInTheDocument();
});

it('opens dropdown and fires callbacks on click', async () => {
const user = userEvent.setup();
const handleViewDetails = vi.fn();
const handleCopyLink = vi.fn();

render(
<InvoiceRowActions
invoiceId={mockInvoiceId}
onViewDetails={handleViewDetails}
onCopyPaymentLink={handleCopyLink}
/>
);

const triggerButton = screen.getByRole('button', { name: /invoice actions/i });

// Open dropdown
await user.click(triggerButton);

const viewDetailsOption = await screen.findByText('View Details');
const copyLinkOption = await screen.findByText('Copy Payment Link');

expect(viewDetailsOption).toBeInTheDocument();
expect(copyLinkOption).toBeInTheDocument();

// Click view details
await user.click(viewDetailsOption);
expect(handleViewDetails).toHaveBeenCalledWith(mockInvoiceId);
expect(handleViewDetails).toHaveBeenCalledTimes(1);

// Click copy link (re-open first)
await user.click(triggerButton);
const copyLinkOption2 = await screen.findByText('Copy Payment Link');
await user.click(copyLinkOption2);
expect(handleCopyLink).toHaveBeenCalledWith(mockInvoiceId);
expect(handleCopyLink).toHaveBeenCalledTimes(1);
});
});
51 changes: 51 additions & 0 deletions src/components/invoices/InvoiceRowActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use client';

import * as React from 'react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { MoreVertical, Eye, Link as LinkIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';

export interface InvoiceRowActionsProps {
invoiceId: string;
onViewDetails: (invoiceId: string) => void;
onCopyPaymentLink: (invoiceId: string) => void;
}

export function InvoiceRowActions({
invoiceId,
onViewDetails,
onCopyPaymentLink,
}: InvoiceRowActionsProps) {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button variant="ghost" size="icon" aria-label="Invoice actions">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenu.Trigger>

<DropdownMenu.Portal>
<DropdownMenu.Content
align="end"
className="z-50 min-w-[8rem] overflow-hidden rounded-md border bg-white p-1 text-slate-950 shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
>
<DropdownMenu.Item
className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
onClick={() => onViewDetails(invoiceId)}
>
<Eye className="mr-2 h-4 w-4" />
<span>View Details</span>
</DropdownMenu.Item>

<DropdownMenu.Item
className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
onClick={() => onCopyPaymentLink(invoiceId)}
>
<LinkIcon className="mr-2 h-4 w-4" />
<span>Copy Payment Link</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
68 changes: 68 additions & 0 deletions src/components/payments/PaymentMethodTabs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { PaymentMethodTabs } from './PaymentMethodTabs';

describe('PaymentMethodTabs', () => {
const walletContent = <div data-testid="wallet-content">Wallet Content</div>;
const manualContent = <div data-testid="manual-content">Manual Content</div>;

it('renders default tab (wallet)', () => {
render(
<PaymentMethodTabs
walletContent={walletContent}
manualTransferContent={manualContent}
/>
);

expect(screen.getByRole('tab', { name: 'Connect Wallet' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Manual Transfer' })).toBeInTheDocument();

expect(screen.getByTestId('wallet-content')).toBeInTheDocument();
expect(screen.queryByTestId('manual-content')).not.toBeInTheDocument();
});

it('switches to manual transfer tab', async () => {
const user = userEvent.setup();
render(
<PaymentMethodTabs
walletContent={walletContent}
manualTransferContent={manualContent}
/>
);

const manualTab = screen.getByRole('tab', { name: 'Manual Transfer' });
await user.click(manualTab);

await waitFor(() => {
expect(screen.getByTestId('manual-content')).toBeInTheDocument();
});
expect(screen.queryByTestId('wallet-content')).not.toBeInTheDocument();
});

it('switches back to wallet tab', async () => {
const user = userEvent.setup();
render(
<PaymentMethodTabs
walletContent={walletContent}
manualTransferContent={manualContent}
/>
);

const manualTab = screen.getByRole('tab', { name: 'Manual Transfer' });
const walletTab = screen.getByRole('tab', { name: 'Connect Wallet' });

// Switch to manual
await user.click(manualTab);
await waitFor(() => {
expect(screen.getByTestId('manual-content')).toBeInTheDocument();
});

// Switch back to wallet
await user.click(walletTab);
await waitFor(() => {
expect(screen.getByTestId('wallet-content')).toBeInTheDocument();
});
expect(screen.queryByTestId('manual-content')).not.toBeInTheDocument();
});
});
Loading