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
65 changes: 65 additions & 0 deletions src/components/features/deposits/DepositOptionCard.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useState } from 'react';
import { Card } from '../../ui/Card';
import { Button } from '../../ui/Button';
import { useTranslation } from 'react-i18next';

const COPYABLE_KEYS = [
'cbu_cvu',
'alias',
'lemon_tag',
'address',
'swift_code',
'account_number',
'iban',
];

export const DepositOptionCard = ({ option }) => {
const [copiedKey, setCopiedKey] = useState(null);
const { t } = useTranslation();

const handleCopy = async (value, key) => {
try {
await navigator.clipboard.writeText(value);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};

const details = option.details || {};
const detailEntries = Object.entries(details).filter(([, v]) => v);

return (
<Card className="hover:shadow-lg transition-shadow">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-semibold text-gray-900">{option.label}</h4>
<span className="text-xs font-medium text-gray-500 bg-gray-100 rounded-full px-2 py-0.5">
{option.currency}
</span>
</div>

<div className="space-y-2">
{detailEntries.map(([key, value]) => (
<div key={key} className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-xs text-gray-500">{t(`deposits.detailLabels.${key}`, key)}</p>
<p className="text-sm text-gray-900 font-mono break-all">{value}</p>
</div>
{COPYABLE_KEYS.includes(key) && (
<Button
onClick={() => handleCopy(value, key)}
variant="outline"
className="shrink-0 text-xs py-1 px-3"
>
{copiedKey === key ? t('deposits.copied') : t('deposits.copy')}
</Button>
)}
</div>
))}
</div>
</div>
</Card>
);
};
56 changes: 56 additions & 0 deletions src/components/features/deposits/DepositOptionCard.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { DepositOptionCard } from './DepositOptionCard';

describe('DepositOptionCard', () => {
const bankOption = {
id: '1',
category: 'BANK_ARS',
label: 'Banco Galicia',
currency: 'ARS',
details: { bank_name: 'Galicia', holder: 'Winbit SRL', cbu_cvu: '0070000000' },
};

const cryptoOption = {
id: '2',
category: 'CRYPTO',
label: 'USDT TRC20',
currency: 'USDT',
details: { address: 'TF7j33woKnMVFALtvRVdnFWnneNrUCVvAr', network: 'TRC20' },
};

it('renders bank option details', () => {
render(<DepositOptionCard option={bankOption} />);
expect(screen.getByText('Banco Galicia')).toBeInTheDocument();
expect(screen.getByText('ARS')).toBeInTheDocument();
expect(screen.getByText('Galicia')).toBeInTheDocument();
expect(screen.getByText('Winbit SRL')).toBeInTheDocument();
expect(screen.getByText('0070000000')).toBeInTheDocument();
});

it('renders crypto option details', () => {
render(<DepositOptionCard option={cryptoOption} />);
expect(screen.getByText('USDT TRC20')).toBeInTheDocument();
expect(screen.getByText('TF7j33woKnMVFALtvRVdnFWnneNrUCVvAr')).toBeInTheDocument();
expect(screen.getByText('TRC20')).toBeInTheDocument();
});

it('has copy buttons for copyable fields', () => {
render(<DepositOptionCard option={bankOption} />);
const copyButtons = screen.getAllByRole('button', { name: /Copiar/i });
expect(copyButtons.length).toBeGreaterThan(0);
});

it('copies text to clipboard on button click', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });

render(<DepositOptionCard option={bankOption} />);
const copyButtons = screen.getAllByRole('button', { name: /Copiar/i });
fireEvent.click(copyButtons[0]);

await waitFor(() => {
expect(writeText).toHaveBeenCalledWith('0070000000');
});
});
});
46 changes: 46 additions & 0 deletions src/components/features/deposits/DepositOptionsList.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { DepositOptionCard } from './DepositOptionCard';
import { EmptyState } from '../../ui/EmptyState';
import { useTranslation } from 'react-i18next';

const CATEGORY_ORDER = ['CASH_ARS', 'CASH_USD', 'BANK_ARS', 'LEMON', 'CRYPTO', 'SWIFT'];

export const DepositOptionsList = ({ options }) => {
const { t } = useTranslation();

if (!options || options.length === 0) {
return (
<EmptyState
icon="💰"
title={t('deposits.noOptionsTitle')}
description={t('deposits.noOptionsMessage')}
/>
);
}

const grouped = {};
for (const opt of options) {
if (!grouped[opt.category]) {
grouped[opt.category] = [];
}
grouped[opt.category].push(opt);
}

const sortedCategories = CATEGORY_ORDER.filter((cat) => grouped[cat]);

return (
<div className="space-y-6">
{sortedCategories.map((category) => (
<div key={category}>
<h3 className="text-lg font-semibold text-gray-800 mb-3">
{t(`deposits.categories.${category}`)}
</h3>
<div className="grid gap-4 md:grid-cols-2">
{grouped[category].map((opt) => (
<DepositOptionCard key={opt.id} option={opt} />
))}
</div>
</div>
))}
</div>
);
};
57 changes: 57 additions & 0 deletions src/components/features/deposits/DepositOptionsList.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { DepositOptionsList } from './DepositOptionsList';

describe('DepositOptionsList', () => {
const mockOptions = [
{
id: '1',
category: 'BANK_ARS',
label: 'Banco Galicia',
currency: 'ARS',
details: { bank_name: 'Galicia', holder: 'Winbit SRL', cbu_cvu: '0070000' },
position: 1,
},
{
id: '2',
category: 'CRYPTO',
label: 'USDT TRC20',
currency: 'USDT',
details: { address: 'TF7j33wo', network: 'TRC20' },
position: 2,
},
{
id: '3',
category: 'LEMON',
label: 'Lemon Cash',
currency: 'ARS',
details: { lemon_tag: '$winbit' },
position: 3,
},
];

it('renders options grouped by category', () => {
render(<DepositOptionsList options={mockOptions} />);

expect(screen.getByText('Transferencia bancaria ARS')).toBeInTheDocument();
expect(screen.getAllByText('Lemon Cash').length).toBeGreaterThan(0);
expect(screen.getByText('Cripto')).toBeInTheDocument();
});

it('renders all option labels', () => {
render(<DepositOptionsList options={mockOptions} />);

expect(screen.getByText('Banco Galicia')).toBeInTheDocument();
expect(screen.getByText('USDT TRC20')).toBeInTheDocument();
});

it('renders empty state when no options', () => {
render(<DepositOptionsList options={[]} />);
expect(screen.getByText(/No hay opciones disponibles/)).toBeInTheDocument();
});

it('renders empty state when options is null', () => {
render(<DepositOptionsList options={null} />);
expect(screen.getByText(/No hay opciones disponibles/)).toBeInTheDocument();
});
});
54 changes: 43 additions & 11 deletions src/components/features/requests/DepositForm.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { Card } from '../../ui/Card';
import { Input } from '../../ui/Input';
import { Select } from '../../ui/Select';
Expand All @@ -9,25 +9,57 @@ import { useTranslation } from 'react-i18next';

const CASH_METHODS = ['CASH_ARS', 'CASH_USD'];

export const DepositForm = ({ userEmail }) => {
const FALLBACK_METHODS = [
{ value: 'CASH_ARS', labelKey: 'requests.method.cash_ars' },
{ value: 'CASH_USD', labelKey: 'requests.method.cash_usd' },
{ value: 'TRANSFER_ARS', labelKey: 'requests.method.transfer_ars' },
{ value: 'SWIFT', labelKey: 'requests.method.swift' },
{ value: 'CRYPTO', labelKey: 'requests.method.crypto' },
];

const CATEGORY_TO_METHOD = {
CASH_ARS: 'CASH_ARS',
CASH_USD: 'CASH_USD',
BANK_ARS: 'TRANSFER_ARS',
LEMON: 'LEMON',
CRYPTO: 'CRYPTO',
SWIFT: 'SWIFT',
};

export const DepositForm = ({ userEmail, depositOptions = [] }) => {
const { t } = useTranslation();

const methodOptions = useMemo(() => {
if (!depositOptions || depositOptions.length === 0) {
return FALLBACK_METHODS.map((m) => ({ value: m.value, label: t(m.labelKey) }));
}

const seen = new Set();
const methods = [];
for (const opt of depositOptions) {
const method = CATEGORY_TO_METHOD[opt.category] || opt.category;
if (!seen.has(method)) {
seen.add(method);
methods.push({
value: method,
label: t(`deposits.categories.${opt.category}`, opt.category),
});
}
}
return methods;
}, [depositOptions, t]);

const defaultMethod = methodOptions[0]?.value || 'CASH_ARS';

const [formData, setFormData] = useState({
amount: '',
method: 'CASH_ARS',
method: defaultMethod,
});
const [attachment, setAttachment] = useState(null);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState(null);
const [modal, setModal] = useState(null);

const methodOptions = [
{ value: 'CASH_ARS', label: t('requests.method.cash_ars') },
{ value: 'CASH_USD', label: t('requests.method.cash_usd') },
{ value: 'TRANSFER_ARS', label: t('requests.method.transfer_ars') },
{ value: 'SWIFT', label: t('requests.method.swift') },
{ value: 'CRYPTO', label: t('requests.method.crypto') },
];

const isCash = CASH_METHODS.includes(formData.method);
const attachmentRequired = !isCash;

Expand Down
50 changes: 44 additions & 6 deletions src/components/features/requests/DepositForm.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,30 @@ vi.mock('../../../services/api', () => ({
createInvestorRequest: vi.fn(),
}));

const mockDepositOptions = [
{ id: '1', category: 'CASH_ARS', label: 'Efectivo', currency: 'ARS', details: {} },
{
id: '2',
category: 'BANK_ARS',
label: 'Galicia',
currency: 'ARS',
details: { bank_name: 'Galicia', holder: 'Winbit', cbu_cvu: '007' },
},
{
id: '3',
category: 'CRYPTO',
label: 'USDT TRC20',
currency: 'USDT',
details: { address: 'TF7j', network: 'TRC20' },
},
];

describe('DepositForm', () => {
it('renders English strings when language is en', async () => {
await act(async () => {
await i18n.changeLanguage('en');
});
render(<DepositForm userName="Test" userEmail="t@e.com" />);
render(<DepositForm userEmail="t@e.com" depositOptions={mockDepositOptions} />);

expect(screen.getByText('Register deposit')).toBeInTheDocument();

Expand All @@ -30,7 +48,9 @@ describe('DepositForm', () => {
await act(async () => {
await i18n.changeLanguage('es');
});
const { container } = render(<DepositForm userName="Test" userEmail="t@e.com" />);
const { container } = render(
<DepositForm userEmail="t@e.com" depositOptions={mockDepositOptions} />,
);
fireEvent.submit(container.querySelector('form'));
expect(await screen.findByRole('alert')).toHaveTextContent('Ingresá un monto válido');
});
Expand All @@ -40,7 +60,9 @@ describe('DepositForm', () => {
await i18n.changeLanguage('es');
});
createInvestorRequest.mockResolvedValueOnce({ data: { id: 1 }, error: null });
const { container } = render(<DepositForm userName="Test" userEmail="t@e.com" />);
const { container } = render(
<DepositForm userEmail="t@e.com" depositOptions={mockDepositOptions} />,
);

fireEvent.change(screen.getByLabelText(/Monto/), { target: { value: '10' } });

Expand All @@ -60,19 +82,35 @@ describe('DepositForm', () => {
);
});

// Modal should appear with success message
expect(await screen.findByText('Solicitud registrada')).toBeInTheDocument();
// Modal should have an "Aceptar" button
expect(screen.getByRole('button', { name: /Aceptar/i })).toBeInTheDocument();
});

it('shows service error', async () => {
createInvestorRequest.mockResolvedValueOnce({ data: null, error: 'Fail' });
const { container } = render(<DepositForm userName="Test" userEmail="t@e.com" />);
const { container } = render(
<DepositForm userEmail="t@e.com" depositOptions={mockDepositOptions} />,
);

fireEvent.change(screen.getByLabelText(/Monto/), { target: { value: '10' } });
fireEvent.submit(container.querySelector('form'));

expect(await screen.findByRole('alert')).toHaveTextContent('Fail');
});

it('derives method options from deposit options', () => {
render(<DepositForm userEmail="t@e.com" depositOptions={mockDepositOptions} />);

const selectButton = screen.getByLabelText(/Método/);
expect(selectButton).toBeInTheDocument();
expect(selectButton.textContent).toContain('Efectivo ARS');
});

it('falls back to hardcoded methods when no deposit options', () => {
render(<DepositForm userEmail="t@e.com" depositOptions={[]} />);

const selectButton = screen.getByLabelText(/Método/);
expect(selectButton).toBeInTheDocument();
expect(selectButton.textContent).toContain('Efectivo ARS');
});
});
Loading