diff --git a/apps/web/components/watchlists/WatchlistCreationModal.spec.tsx b/apps/web/components/watchlists/WatchlistCreationModal.spec.tsx new file mode 100644 index 0000000..5471199 --- /dev/null +++ b/apps/web/components/watchlists/WatchlistCreationModal.spec.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { WatchlistCreationModal } from './WatchlistCreationModal'; + +describe('WatchlistCreationModal', () => { + it('requires a name and at least one target', () => { + const onCreate = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /create watchlist/i })); + + expect(screen.getByText('Enter a name for this watchlist.')).toBeTruthy(); + expect(screen.getByText('Add a wallet address, contract address, or both.')).toBeTruthy(); + expect(onCreate).not.toHaveBeenCalled(); + }); + + it('submits trimmed values and closes after creation succeeds', async () => { + const onCreate = jest.fn().mockResolvedValue(undefined); + const onClose = jest.fn(); + render(); + + fireEvent.change(screen.getByLabelText('Watchlist name'), { + target: { value: ' Treasury ' }, + }); + fireEvent.change(screen.getByLabelText(/wallet address/i), { + target: { value: ' GABC123 ' }, + }); + fireEvent.click(screen.getByRole('button', { name: /create watchlist/i })); + + await waitFor(() => + expect(onCreate).toHaveBeenCalledWith({ + label: 'Treasury', + walletAddress: 'GABC123', + contractAddress: undefined, + }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('keeps the dialog open and reports a submission error', async () => { + const onCreate = jest.fn().mockRejectedValue(new Error('Address could not be saved.')); + const onClose = jest.fn(); + render(); + + fireEvent.change(screen.getByLabelText('Watchlist name'), { target: { value: 'Treasury' } }); + fireEvent.change(screen.getByLabelText(/contract address/i), { + target: { value: 'contract-1' }, + }); + fireEvent.click(screen.getByRole('button', { name: /create watchlist/i })); + + expect((await screen.findByRole('alert')).textContent).toContain('Address could not be saved.'); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/watchlists/WatchlistCreationModal.tsx b/apps/web/components/watchlists/WatchlistCreationModal.tsx new file mode 100644 index 0000000..1ebd3f4 --- /dev/null +++ b/apps/web/components/watchlists/WatchlistCreationModal.tsx @@ -0,0 +1,190 @@ +'use client'; + +import { useState } from 'react'; +import './watchlists.css'; + +export interface WatchlistCreationPayload { + label: string; + walletAddress?: string; + contractAddress?: string; +} + +interface WatchlistCreationModalProps { + onClose: () => void; + onCreate: (payload: WatchlistCreationPayload) => Promise | void; +} + +type FormErrors = Partial>; + +const MAX_LABEL_LENGTH = 100; +const MAX_ADDRESS_LENGTH = 256; + +export function WatchlistCreationModal({ onClose, onCreate }: WatchlistCreationModalProps) { + const [label, setLabel] = useState(''); + const [walletAddress, setWalletAddress] = useState(''); + const [contractAddress, setContractAddress] = useState(''); + const [errors, setErrors] = useState({}); + const [submitError, setSubmitError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const validate = (): FormErrors => { + const nextErrors: FormErrors = {}; + const trimmedLabel = label.trim(); + const trimmedWallet = walletAddress.trim(); + const trimmedContract = contractAddress.trim(); + + if (!trimmedLabel) nextErrors.label = 'Enter a name for this watchlist.'; + else if (trimmedLabel.length > MAX_LABEL_LENGTH) { + nextErrors.label = `Name must be ${MAX_LABEL_LENGTH} characters or fewer.`; + } + + if (!trimmedWallet && !trimmedContract) { + nextErrors.targets = 'Add a wallet address, contract address, or both.'; + } + if (trimmedWallet.length > MAX_ADDRESS_LENGTH) { + nextErrors.walletAddress = 'Wallet address is too long.'; + } + if (trimmedContract.length > MAX_ADDRESS_LENGTH) { + nextErrors.contractAddress = 'Contract address is too long.'; + } + if (trimmedWallet && trimmedContract && trimmedWallet === trimmedContract) { + nextErrors.targets = 'Wallet and contract addresses must be different.'; + } + + return nextErrors; + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setSubmitError(''); + + const nextErrors = validate(); + setErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) return; + + setIsSubmitting(true); + try { + await onCreate({ + label: label.trim(), + walletAddress: walletAddress.trim() || undefined, + contractAddress: contractAddress.trim() || undefined, + }); + onClose(); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : 'Unable to create watchlist.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
event.stopPropagation()} + > +
+
+

Monitoring

+

Create watchlist

+

Choose the addresses Sentinel should keep an eye on.

+
+ +
+ +
+
+ + setLabel(event.target.value)} + aria-invalid={Boolean(errors.label)} + aria-describedby={errors.label ? 'watchlist-label-error' : undefined} + placeholder="e.g. Treasury movement" + maxLength={MAX_LABEL_LENGTH} + disabled={isSubmitting} + /> + {errors.label && ( +

+ {errors.label} +

+ )} +
+ +
+ + setWalletAddress(event.target.value)} + aria-invalid={Boolean(errors.walletAddress || errors.targets)} + aria-describedby={errors.walletAddress ? 'watchlist-wallet-error' : undefined} + placeholder="Paste a wallet address" + maxLength={MAX_ADDRESS_LENGTH} + disabled={isSubmitting} + /> + {errors.walletAddress && ( +

+ {errors.walletAddress} +

+ )} +
+ +
+ + setContractAddress(event.target.value)} + aria-invalid={Boolean(errors.contractAddress || errors.targets)} + aria-describedby={errors.contractAddress ? 'watchlist-contract-error' : undefined} + placeholder="Paste a contract address" + maxLength={MAX_ADDRESS_LENGTH} + disabled={isSubmitting} + /> + {errors.contractAddress && ( +

+ {errors.contractAddress} +

+ )} +
+ + {errors.targets && ( +

+ {errors.targets} +

+ )} + {submitError && ( +

+ {submitError} +

+ )} + + +
+
+
+ ); +} diff --git a/apps/web/components/watchlists/watchlists.css b/apps/web/components/watchlists/watchlists.css new file mode 100644 index 0000000..4700c48 --- /dev/null +++ b/apps/web/components/watchlists/watchlists.css @@ -0,0 +1,151 @@ +.watchlist-modal-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + padding: 1rem; + background: rgb(8 15 29 / 72%); +} + +.watchlist-modal { + width: min(100%, 32rem); + max-height: calc(100dvh - 2rem); + overflow-y: auto; + border: 1px solid #c9d5e6; + border-radius: 0.75rem; + background: #f8fafc; + color: #142238; + box-shadow: 0 1.5rem 4rem rgb(5 15 35 / 28%); +} + +.watchlist-modal-header { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 1.5rem 1.5rem 1rem; + border-bottom: 1px solid #dbe4ef; +} + +.watchlist-modal-eyebrow { + margin: 0 0 0.35rem; + color: #147d75; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.watchlist-modal h2 { + margin: 0; + font-size: 1.45rem; + line-height: 1.2; +} + +.watchlist-modal-header p:last-child { + margin: 0.45rem 0 0; + color: #52647c; + font-size: 0.9rem; +} + +.watchlist-modal-close { + align-self: flex-start; + border: 0; + background: transparent; + color: #52647c; + cursor: pointer; + font-size: 1.5rem; + line-height: 1; +} + +.watchlist-modal form { + padding: 1.25rem 1.5rem 1.5rem; +} + +.watchlist-field { + display: grid; + gap: 0.4rem; + margin-bottom: 1rem; +} + +.watchlist-field label { + color: #263b56; + font-size: 0.85rem; + font-weight: 700; +} + +.watchlist-field label span { + color: #718198; + font-weight: 400; +} + +.watchlist-field input { + width: 100%; + box-sizing: border-box; + border: 1px solid #b8c7da; + border-radius: 0.4rem; + padding: 0.7rem 0.75rem; + background: #fff; + color: #142238; + font: inherit; +} + +.watchlist-field input:focus { + outline: 3px solid rgb(20 125 117 / 18%); + border-color: #147d75; +} + +.watchlist-field input[aria-invalid='true'] { + border-color: #c24146; +} + +.watchlist-error { + margin: 0.35rem 0 0; + color: #a52d37; + font-size: 0.8rem; +} + +.watchlist-modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.watchlist-modal-actions button { + border: 1px solid #b8c7da; + border-radius: 0.4rem; + padding: 0.65rem 1rem; + cursor: pointer; + font: inherit; + font-weight: 700; +} + +.watchlist-modal-actions button:first-child { + background: #fff; + color: #314661; +} + +.watchlist-modal-actions button:last-child { + border-color: #147d75; + background: #147d75; + color: #fff; +} + +.watchlist-modal-actions button:disabled { + cursor: wait; + opacity: 0.6; +} + +@media (max-width: 32rem) { + .watchlist-modal-header, + .watchlist-modal form { + padding-left: 1rem; + padding-right: 1rem; + } + + .watchlist-modal-actions { + display: grid; + grid-template-columns: 1fr 1fr; + } +}