From bd762e7e69e1ea1a6323c5431dabeaf27304aa4b Mon Sep 17 00:00:00 2001 From: Sergo Date: Tue, 3 Mar 2026 18:21:27 +0000 Subject: [PATCH 01/33] feat: allow to download receipt --- .../lib/__tests__/useDownloadReceipt.test.ts | 134 ++++++++++++++++++ .../receipt/lib/useDownloadReceipt.ts | 41 ++++++ 2 files changed, 175 insertions(+) create mode 100644 app/features/receipt/lib/__tests__/useDownloadReceipt.test.ts create mode 100644 app/features/receipt/lib/useDownloadReceipt.ts diff --git a/app/features/receipt/lib/__tests__/useDownloadReceipt.test.ts b/app/features/receipt/lib/__tests__/useDownloadReceipt.test.ts new file mode 100644 index 000000000..e0fb2a8f1 --- /dev/null +++ b/app/features/receipt/lib/__tests__/useDownloadReceipt.test.ts @@ -0,0 +1,134 @@ +import { act, renderHook } from '@testing-library/react'; +import { vi } from 'vitest'; + +// import type { DownloadState } from '../useDownloadReceipt'; + +describe('useDownloadReceipt', () => { + let useDownloadReceipt: typeof import('../useDownloadReceipt').useDownloadReceipt; + + beforeEach(async () => { + vi.useFakeTimers(); + ({ useDownloadReceipt } = await import('../useDownloadReceipt')); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('should start with idle state', () => { + const download = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useDownloadReceipt(download)); + expect(result.current[0]).toBe('idle'); + }); + + it('should transition to downloading then downloaded on success', async () => { + let resolve: () => void; + const download = vi.fn().mockReturnValue( + new Promise(r => { + resolve = r; + }) + ); + + const { result } = renderHook(() => useDownloadReceipt(download)); + + act(() => { + result.current[1](); + }); + + expect(result.current[0]).toBe('downloading'); + + await act(async () => { + resolve!(); + }); + + expect(result.current[0]).toBe('downloaded'); + }); + + it('should transition to errored on failure', async () => { + const download = vi.fn().mockRejectedValue(new Error('network error')); + const { result } = renderHook(() => useDownloadReceipt(download)); + + await act(async () => { + result.current[1](); + }); + + expect(result.current[0]).toBe('errored'); + }); + + it('should reset to idle after resetMs', async () => { + const download = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useDownloadReceipt(download, 500)); + + await act(async () => { + result.current[1](); + }); + + expect(result.current[0]).toBe('downloaded'); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(result.current[0]).toBe('idle'); + }); + + it('should reset to idle after resetMs when errored', async () => { + const download = vi.fn().mockRejectedValue(new Error('fail')); + const { result } = renderHook(() => useDownloadReceipt(download, 500)); + + await act(async () => { + result.current[1](); + }); + + expect(result.current[0]).toBe('errored'); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(result.current[0]).toBe('idle'); + }); + + it('should ignore clicks while downloading', async () => { + let resolve: () => void; + const download = vi.fn().mockReturnValue( + new Promise(r => { + resolve = r; + }) + ); + + const { result } = renderHook(() => useDownloadReceipt(download)); + + act(() => { + result.current[1](); + }); + + expect(download).toHaveBeenCalledTimes(1); + + act(() => { + result.current[1](); + }); + + expect(download).toHaveBeenCalledTimes(1); + + await act(async () => { + resolve!(); + }); + }); + + it('should clean up timeout on unmount', async () => { + const download = vi.fn().mockResolvedValue(undefined); + const { result, unmount } = renderHook(() => useDownloadReceipt(download, 1000)); + + await act(async () => { + result.current[1](); + }); + + unmount(); + + act(() => { + vi.advanceTimersByTime(1000); + }); + }); +}); diff --git a/app/features/receipt/lib/useDownloadReceipt.ts b/app/features/receipt/lib/useDownloadReceipt.ts new file mode 100644 index 000000000..df46effaa --- /dev/null +++ b/app/features/receipt/lib/useDownloadReceipt.ts @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { DownloadReceiptFn } from '../types'; + +export type DownloadState = 'idle' | 'downloading' | 'downloaded' | 'errored'; + +export function useDownloadReceipt(download: DownloadReceiptFn, resetMs = 2000): readonly [DownloadState, () => void] { + const [state, setState] = useState('idle'); + const timeoutRef = useRef>(); + + useEffect(() => { + return () => { + clearTimeout(timeoutRef.current); + }; + }, []); + + const scheduleReset = useCallback(() => { + timeoutRef.current = setTimeout(() => setState('idle'), resetMs); + }, [resetMs]); + + const trigger = useCallback(() => { + if (state === 'downloading') return; + + clearTimeout(timeoutRef.current); + setState('downloading'); + + download().then( + () => { + setState('downloaded'); + scheduleReset(); + }, + (error: unknown) => { + console.error('Download failed:', error); + setState('errored'); + scheduleReset(); + } + ); + }, [state, download, scheduleReset]); + + return [state, trigger] as const; +} From 21788a3f8cb323945d92b9cba9cd47287c0d8549 Mon Sep 17 00:00:00 2001 From: Sergo Date: Tue, 3 Mar 2026 18:27:22 +0000 Subject: [PATCH 02/33] wip --- .../lib/__tests__/useDownloadReceipt.test.ts | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/app/features/receipt/lib/__tests__/useDownloadReceipt.test.ts b/app/features/receipt/lib/__tests__/useDownloadReceipt.test.ts index e0fb2a8f1..03be89f83 100644 --- a/app/features/receipt/lib/__tests__/useDownloadReceipt.test.ts +++ b/app/features/receipt/lib/__tests__/useDownloadReceipt.test.ts @@ -1,8 +1,6 @@ import { act, renderHook } from '@testing-library/react'; import { vi } from 'vitest'; -// import type { DownloadState } from '../useDownloadReceipt'; - describe('useDownloadReceipt', () => { let useDownloadReceipt: typeof import('../useDownloadReceipt').useDownloadReceipt; @@ -23,7 +21,7 @@ describe('useDownloadReceipt', () => { }); it('should transition to downloading then downloaded on success', async () => { - let resolve: () => void; + let resolve: () => void = () => {}; const download = vi.fn().mockReturnValue( new Promise(r => { resolve = r; @@ -39,7 +37,7 @@ describe('useDownloadReceipt', () => { expect(result.current[0]).toBe('downloading'); await act(async () => { - resolve!(); + resolve(); }); expect(result.current[0]).toBe('downloaded'); @@ -91,7 +89,7 @@ describe('useDownloadReceipt', () => { }); it('should ignore clicks while downloading', async () => { - let resolve: () => void; + let resolve: () => void = () => {}; const download = vi.fn().mockReturnValue( new Promise(r => { resolve = r; @@ -113,8 +111,32 @@ describe('useDownloadReceipt', () => { expect(download).toHaveBeenCalledTimes(1); await act(async () => { - resolve!(); + resolve(); + }); + }); + + it('should allow re-download after reset', async () => { + const download = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useDownloadReceipt(download, 500)); + + await act(async () => { + result.current[1](); }); + + expect(result.current[0]).toBe('downloaded'); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(result.current[0]).toBe('idle'); + + await act(async () => { + result.current[1](); + }); + + expect(result.current[0]).toBe('downloaded'); + expect(download).toHaveBeenCalledTimes(2); }); it('should clean up timeout on unmount', async () => { From c4fb7e257bc9c8099428cad03e09039d73592989 Mon Sep 17 00:00:00 2001 From: Sergo Date: Tue, 3 Mar 2026 20:39:44 +0000 Subject: [PATCH 03/33] WIP: PDF generation --- .../__tests__/generate-receipt-pdf.test.ts | 188 +++++++++++++++ .../receipt/ui/BasePrintableReceipt.tsx | 217 ++++++++++++++++++ .../receipt/ui/PrintableReceiptView.tsx | 105 +++++++++ .../stories/BasePrintableReceipt.stories.tsx | 98 ++++++++ app/styles.css | 52 +++++ 5 files changed, 660 insertions(+) create mode 100644 app/features/receipt/lib/__tests__/generate-receipt-pdf.test.ts create mode 100644 app/features/receipt/ui/BasePrintableReceipt.tsx create mode 100644 app/features/receipt/ui/PrintableReceiptView.tsx create mode 100644 app/features/receipt/ui/stories/BasePrintableReceipt.stories.tsx diff --git a/app/features/receipt/lib/__tests__/generate-receipt-pdf.test.ts b/app/features/receipt/lib/__tests__/generate-receipt-pdf.test.ts new file mode 100644 index 000000000..05e4486cf --- /dev/null +++ b/app/features/receipt/lib/__tests__/generate-receipt-pdf.test.ts @@ -0,0 +1,188 @@ +import { vi } from 'vitest'; + +import type { FormattedReceipt } from '../../types'; + +const mockTextField = vi.fn(() => ({ + defaultValue: '', + fieldName: '', + fontSize: 0, + height: 0, + value: '', + width: 0, + x: 0, + y: 0, +})); + +const mockSave = vi.fn(); +const mockAddField = vi.fn(); +const mockText = vi.fn(); +const mockAddImage = vi.fn(); +const mockSplitTextToSize = vi.fn((text: string, _maxWidth: number) => [text]); + +const mockDoc = { + AcroForm: { + TextField: mockTextField, + }, + addField: mockAddField, + addImage: mockAddImage, + line: vi.fn(), + rect: vi.fn(), + save: mockSave, + setDrawColor: vi.fn(), + setFillColor: vi.fn(), + setFont: vi.fn(), + setFontSize: vi.fn(), + setLineWidth: vi.fn(), + setTextColor: vi.fn(), + splitTextToSize: mockSplitTextToSize, + text: mockText, +}; + +vi.mock('jspdf', () => ({ + jsPDF: vi.fn(() => mockDoc), +})); + +const mockToDataURL = vi.fn().mockResolvedValue('data:image/png;base64,qrcode'); + +vi.mock('qrcode', () => ({ + default: { toDataURL: (...args: unknown[]) => mockToDataURL(...args) }, + toDataURL: (...args: unknown[]) => mockToDataURL(...args), +})); + +const RECEIPT: FormattedReceipt = { + date: { timestamp: 1700000000, utc: '2023-11-14 22:13:20 UTC' }, + fee: { formatted: '0.000005', raw: 5000 }, + memo: 'Payment for services', + network: 'mainnet-beta', + receiver: { address: 'ReceiverAddr2222222222222222222222222222222', truncated: 'Recv...2222' }, + sender: { address: 'SenderAddr111111111111111111111111111111111', truncated: 'Send...1111' }, + total: { formatted: '1.0', raw: 1000000000, unit: 'SOL' }, +}; + +const SIGNATURE = '5UfDuX7hXbGjGHqPXRGaHdSecretSignature1234567890abcdef'; +const RECEIPT_URL = 'https://explorer.solana.com/receipt/5UfDuX7hXbGjGHqPXRGaHdSecretSignature1234567890abcdef'; + +describe('generateReceiptPdf', () => { + let generateReceiptPdf: typeof import('../generate-receipt-pdf').generateReceiptPdf; + + beforeEach(async () => { + vi.clearAllMocks(); + ({ generateReceiptPdf } = await import('../generate-receipt-pdf')); + }); + + it('should create jsPDF instance with A4 format', async () => { + const { jsPDF } = await import('jspdf'); + await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + expect(jsPDF).toHaveBeenCalledWith({ format: 'a4', unit: 'mm' }); + }); + + it('should add pre-filled text for all payment detail fields', async () => { + await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + + const textCalls = mockText.mock.calls.map(([text]: [string | string[]]) => + Array.isArray(text) ? text.join(' ') : text + ); + const allText = textCalls.join(' '); + + expect(allText).toContain('Solana Payment Receipt'); + expect(allText).toContain('Solana Blockchain'); + expect(allText).toContain('2023-11-14 22:13:20 UTC'); + expect(allText).toContain('1.0 SOL'); + expect(allText).toContain('SenderAddr111111111111111111111111111111111'); + expect(allText).toContain('ReceiverAddr2222222222222222222222222222222'); + expect(allText).toContain(SIGNATURE); + }); + + it('should create AcroForm text fields for editable sections', async () => { + await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + + const fieldNames = mockTextField.mock.results + .map(r => r.value.fieldName) + .filter(Boolean); + + expect(fieldNames).toContain('supplier_name'); + expect(fieldNames).toContain('supplier_address'); + expect(fieldNames.some((n: string) => n.startsWith('item_0_'))).toBe(true); + expect(fieldNames.some((n: string) => n.startsWith('item_3_'))).toBe(true); + }); + + it('should render Total as static text, not an editable field', async () => { + await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + + const fieldNames = mockTextField.mock.results + .map(r => r.value.fieldName) + .filter(Boolean); + expect(fieldNames).not.toContain('total'); + + const textCalls = mockText.mock.calls.map(([text]: [string | string[]]) => + Array.isArray(text) ? text.join(' ') : text + ); + const allText = textCalls.join(' '); + expect(allText).toContain('1.0 SOL'); + }); + + it('should call doc.save with full signature filename', async () => { + await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + + expect(mockSave).toHaveBeenCalledWith(`solana-receipt-${SIGNATURE}.pdf`); + }); + + it('should handle missing memo gracefully', async () => { + const receiptWithoutMemo: FormattedReceipt = { ...RECEIPT, memo: undefined }; + + await generateReceiptPdf(receiptWithoutMemo, SIGNATURE, RECEIPT_URL); + + const textCalls = mockText.mock.calls.map(([text]: [string | string[]]) => + Array.isArray(text) ? text.join(' ') : text + ); + const allText = textCalls.join(' '); + + expect(allText).not.toContain('Transaction Memo'); + expect(mockSave).toHaveBeenCalled(); + }); + + it('should add editable fields via addField for each AcroForm field', async () => { + await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + + expect(mockAddField).toHaveBeenCalled(); + expect(mockAddField.mock.calls.length).toBeGreaterThan(0); + }); + + it('should include memo in text when present', async () => { + await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + + const textCalls = mockText.mock.calls.map(([text]: [string | string[]]) => + Array.isArray(text) ? text.join(' ') : text + ); + const allText = textCalls.join(' '); + + expect(allText).toContain('Transaction Memo'); + expect(allText).toContain('Payment for services'); + }); + + it('should embed QR code image in the PDF', async () => { + await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + + expect(mockToDataURL).toHaveBeenCalledWith(RECEIPT_URL, { width: 200, margin: 0 }); + + const addImageCalls = mockAddImage.mock.calls; + const qrCall = addImageCalls.find( + ([dataUrl]: [string]) => dataUrl === 'data:image/png;base64,qrcode' + ); + expect(qrCall).toBeDefined(); + + const textCalls = mockText.mock.calls.map(([text]: [string | string[]]) => + Array.isArray(text) ? text.join(' ') : text + ); + const allText = textCalls.join(' '); + expect(allText).toContain('Verify on Solana Explorer'); + }); + + it('should handle QR code generation failure gracefully', async () => { + mockToDataURL.mockRejectedValueOnce(new Error('QR generation failed')); + + await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + + expect(mockSave).toHaveBeenCalledWith(`solana-receipt-${SIGNATURE}.pdf`); + }); +}); diff --git a/app/features/receipt/ui/BasePrintableReceipt.tsx b/app/features/receipt/ui/BasePrintableReceipt.tsx new file mode 100644 index 000000000..3f8528018 --- /dev/null +++ b/app/features/receipt/ui/BasePrintableReceipt.tsx @@ -0,0 +1,217 @@ +import type { FormattedReceipt } from '../types'; +import { Logo } from './Logo'; + +export interface LineItem { + description: string; + qty: string; + total: string; + unitPrice: string; + vatPercent: string; +} + +export interface BasePrintableReceiptProps { + data: FormattedReceipt & { + confirmationStatus?: string; + signature: string; + }; + lineItems: LineItem[]; + logoDataUrl?: string; + onLineItemChange: (index: number, field: keyof LineItem, value: string) => void; + subtotal: string; + supplierAddress: string; + supplierName: string; + vatAmount: string; + onSubtotalChange: (value: string) => void; + onSupplierAddressChange: (value: string) => void; + onSupplierNameChange: (value: string) => void; + onVatAmountChange: (value: string) => void; +} + +export function BasePrintableReceipt({ + data, + lineItems, + logoDataUrl, + onLineItemChange, + onSubtotalChange, + onSupplierAddressChange, + onSupplierNameChange, + onVatAmountChange, + subtotal, + supplierAddress, + supplierName, + vatAmount, +}: BasePrintableReceiptProps) { + const { date, fee, memo, network, receiver, sender, total } = data; + const confirmationStatus = data.confirmationStatus ?? 'Unknown'; + const statusLabel = confirmationStatus.charAt(0).toUpperCase() + confirmationStatus.slice(1).toLowerCase(); + + return ( +
+ {logoDataUrl && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Company logo +
+ )} + +

Solana Payment Receipt

+

On-chain Transaction Record

+ +
+ +

Payment Details

+
+ Payment Method + Solana Blockchain + + Payment Date + {date.utc} + + Original Amount + + {total.formatted} {total.unit} + + + Network Fee + {fee.formatted} SOL + + Network + {network} + + Status + {statusLabel} + + Sender Address + {sender.domain ?? sender.address} + + Receiver Address + {receiver.domain ?? receiver.address} + + Transaction Signature + {data.signature} + + {memo && ( + <> + Memo + {memo} + + )} +
+ +

+ Supplier / Seller Information +

+
+ Full Name + + + Address + +
+ +

Items / Services

+ + + + + + + + + + + + {lineItems.map((item, i) => ( + + + + + + + + ))} + +
DescriptionQtyUnit PriceVAT %Total
+ onLineItemChange(i, 'description', v)} + value={item.description} + /> + + onLineItemChange(i, 'qty', v)} + value={item.qty} + /> + + onLineItemChange(i, 'unitPrice', v)} + value={item.unitPrice} + /> + + onLineItemChange(i, 'vatPercent', v)} + value={item.vatPercent} + /> + + onLineItemChange(i, 'total', v)} + value={item.total} + /> +
+ +
+
+ Subtotal + +
+
+ VAT Amount + +
+
+ Total +
+ {total.formatted} {total.unit} +
+
+
+ +

+ This document is generated from on-chain Solana blockchain data. Editable fields (supplier info, items, + VAT) are provided for the user to complete manually. On-chain data (addresses, amounts, dates) is + pre-filled and verified against the blockchain. This receipt is not a tax invoice unless completed with + appropriate details. +

+ +
+ +
+
+ ); +} + +function EditableInput({ + className, + onChange, + value, +}: { + className?: string; + onChange: (value: string) => void; + value: string; +}) { + return ( + onChange(e.target.value)} + value={value} + /> + ); +} diff --git a/app/features/receipt/ui/PrintableReceiptView.tsx b/app/features/receipt/ui/PrintableReceiptView.tsx new file mode 100644 index 000000000..821a8d4fc --- /dev/null +++ b/app/features/receipt/ui/PrintableReceiptView.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useCallback, useRef, useState } from 'react'; +import { ArrowLeft, Image as ImageIcon, Printer } from 'react-feather'; + +import type { FormattedReceipt } from '../types'; +import { BasePrintableReceipt, type LineItem } from './BasePrintableReceipt'; + +interface PrintableReceiptViewProps { + data: FormattedReceipt & { + confirmationStatus?: string; + signature: string; + }; + onBack: () => void; +} + +const EMPTY_LINE_ITEM: LineItem = { description: '', qty: '', total: '', unitPrice: '', vatPercent: '' }; +const INITIAL_LINE_ITEMS: LineItem[] = Array.from({ length: 4 }, () => ({ ...EMPTY_LINE_ITEM })); + +export function PrintableReceiptView({ data, onBack }: PrintableReceiptViewProps) { + const [supplierName, setSupplierName] = useState(''); + const [supplierAddress, setSupplierAddress] = useState(''); + const [subtotal, setSubtotal] = useState(''); + const [vatAmount, setVatAmount] = useState(''); + const [lineItems, setLineItems] = useState(INITIAL_LINE_ITEMS); + const [logoDataUrl, setLogoDataUrl] = useState(); + const fileInputRef = useRef(null); + + const handleLogoUpload = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = () => { + setLogoDataUrl(reader.result as string); + }; + reader.readAsDataURL(file); + }, []); + + const handlePrint = useCallback(() => { + window.print(); + }, []); + + const handleLineItemChange = useCallback((index: number, field: keyof LineItem, value: string) => { + setLineItems(prev => prev.map((item, i) => (i === index ? { ...item, [field]: value } : item))); + }, []); + + return ( +
+
+ + +
+ + + +
+
+ +
+ +
+
+ ); +} diff --git a/app/features/receipt/ui/stories/BasePrintableReceipt.stories.tsx b/app/features/receipt/ui/stories/BasePrintableReceipt.stories.tsx new file mode 100644 index 000000000..bf9f02139 --- /dev/null +++ b/app/features/receipt/ui/stories/BasePrintableReceipt.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { BasePrintableReceipt, type LineItem } from '../BasePrintableReceipt'; +import { + defaultReceipt, + receiptLargeAmount, + receiptTokenTransfer, + receiptWithDomains, + receiptWithMemo, +} from './receipt-fixtures'; + +const MOCK_SIGNATURE = '5UfDuX7h3bSd5gHSrKaJxYFc7RXvKqTnGjSGZzD3Rt1Xo2gPqAe7rGZtLtKgiXvBbXteu2MngGDPKzJHm6u8RBd'; + +const EMPTY_LINE_ITEM: LineItem = { description: '', qty: '', total: '', unitPrice: '', vatPercent: '' }; +const EMPTY_LINE_ITEMS: LineItem[] = Array.from({ length: 4 }, () => ({ ...EMPTY_LINE_ITEM })); + +function forPrintable(data: typeof defaultReceipt) { + return { + ...data, + confirmationStatus: 'finalized' as const, + signature: MOCK_SIGNATURE, + }; +} + +const noop = () => {}; + +const baseArgs = { + lineItems: EMPTY_LINE_ITEMS, + onLineItemChange: noop, + onSubtotalChange: noop, + onSupplierAddressChange: noop, + onSupplierNameChange: noop, + onVatAmountChange: noop, + subtotal: '', + supplierAddress: '', + supplierName: '', + vatAmount: '', +}; + +const meta: Meta = { + component: BasePrintableReceipt, + title: 'Features/Receipt/BasePrintableReceipt', +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + ...baseArgs, + data: forPrintable(defaultReceipt), + }, +}; + +export const WithMemo: Story = { + args: { + ...baseArgs, + data: forPrintable(receiptWithMemo), + }, +}; + +export const LargeAmount: Story = { + args: { + ...baseArgs, + data: forPrintable(receiptLargeAmount), + }, +}; + +export const WithDomainNames: Story = { + args: { + ...baseArgs, + data: forPrintable(receiptWithDomains), + }, +}; + +export const TokenTransfer: Story = { + args: { + ...baseArgs, + data: forPrintable(receiptTokenTransfer), + }, +}; + +export const WithFilledFields: Story = { + args: { + ...baseArgs, + data: forPrintable(defaultReceipt), + lineItems: [ + { description: 'Consulting services', qty: '10', total: '1000', unitPrice: '100', vatPercent: '20' }, + { description: 'Software license', qty: '1', total: '500', unitPrice: '500', vatPercent: '20' }, + { ...EMPTY_LINE_ITEM }, + { ...EMPTY_LINE_ITEM }, + ], + subtotal: '1500', + supplierAddress: '123 Main St, San Francisco, CA 94105', + supplierName: 'Acme Corp', + vatAmount: '300', + }, +}; diff --git a/app/styles.css b/app/styles.css index ae0d3bc7e..8521291cd 100644 --- a/app/styles.css +++ b/app/styles.css @@ -140,3 +140,55 @@ mask: conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) 50%/21px 100%; } } + +@media print { + .print\:e-hidden { + display: none !important; + } + + .print\:e-border-transparent { + border-color: transparent !important; + } + + .print\:e-bg-transparent { + background-color: transparent !important; + } +} + +/* When a printable receipt is present, hide everything else during print */ +@media print { + body:has(.printable-receipt) { + margin: 0 !important; + padding: 0 !important; + background: white !important; + } + + body:has(.printable-receipt) > *:not(script) { + visibility: hidden !important; + height: 0 !important; + overflow: hidden !important; + padding: 0 !important; + margin: 0 !important; + } + + .printable-receipt { + visibility: visible !important; + position: fixed !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: auto !important; + overflow: visible !important; + background: white !important; + z-index: 999999 !important; + } + + .printable-receipt * { + visibility: visible !important; + } + + @page { + margin: 0.5in; + size: A4; + } +} From 23b09ce37a08e0b4c9947fc2aee5469c31bdd349 Mon Sep 17 00:00:00 2001 From: Sergo Date: Thu, 5 Mar 2026 21:24:04 +0000 Subject: [PATCH 04/33] remove print version --- .../receipt/ui/BasePrintableReceipt.tsx | 217 ------------------ .../receipt/ui/PrintableReceiptView.tsx | 105 --------- .../stories/BasePrintableReceipt.stories.tsx | 98 -------- 3 files changed, 420 deletions(-) delete mode 100644 app/features/receipt/ui/BasePrintableReceipt.tsx delete mode 100644 app/features/receipt/ui/PrintableReceiptView.tsx delete mode 100644 app/features/receipt/ui/stories/BasePrintableReceipt.stories.tsx diff --git a/app/features/receipt/ui/BasePrintableReceipt.tsx b/app/features/receipt/ui/BasePrintableReceipt.tsx deleted file mode 100644 index 3f8528018..000000000 --- a/app/features/receipt/ui/BasePrintableReceipt.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import type { FormattedReceipt } from '../types'; -import { Logo } from './Logo'; - -export interface LineItem { - description: string; - qty: string; - total: string; - unitPrice: string; - vatPercent: string; -} - -export interface BasePrintableReceiptProps { - data: FormattedReceipt & { - confirmationStatus?: string; - signature: string; - }; - lineItems: LineItem[]; - logoDataUrl?: string; - onLineItemChange: (index: number, field: keyof LineItem, value: string) => void; - subtotal: string; - supplierAddress: string; - supplierName: string; - vatAmount: string; - onSubtotalChange: (value: string) => void; - onSupplierAddressChange: (value: string) => void; - onSupplierNameChange: (value: string) => void; - onVatAmountChange: (value: string) => void; -} - -export function BasePrintableReceipt({ - data, - lineItems, - logoDataUrl, - onLineItemChange, - onSubtotalChange, - onSupplierAddressChange, - onSupplierNameChange, - onVatAmountChange, - subtotal, - supplierAddress, - supplierName, - vatAmount, -}: BasePrintableReceiptProps) { - const { date, fee, memo, network, receiver, sender, total } = data; - const confirmationStatus = data.confirmationStatus ?? 'Unknown'; - const statusLabel = confirmationStatus.charAt(0).toUpperCase() + confirmationStatus.slice(1).toLowerCase(); - - return ( -
- {logoDataUrl && ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - Company logo -
- )} - -

Solana Payment Receipt

-

On-chain Transaction Record

- -
- -

Payment Details

-
- Payment Method - Solana Blockchain - - Payment Date - {date.utc} - - Original Amount - - {total.formatted} {total.unit} - - - Network Fee - {fee.formatted} SOL - - Network - {network} - - Status - {statusLabel} - - Sender Address - {sender.domain ?? sender.address} - - Receiver Address - {receiver.domain ?? receiver.address} - - Transaction Signature - {data.signature} - - {memo && ( - <> - Memo - {memo} - - )} -
- -

- Supplier / Seller Information -

-
- Full Name - - - Address - -
- -

Items / Services

- - - - - - - - - - - - {lineItems.map((item, i) => ( - - - - - - - - ))} - -
DescriptionQtyUnit PriceVAT %Total
- onLineItemChange(i, 'description', v)} - value={item.description} - /> - - onLineItemChange(i, 'qty', v)} - value={item.qty} - /> - - onLineItemChange(i, 'unitPrice', v)} - value={item.unitPrice} - /> - - onLineItemChange(i, 'vatPercent', v)} - value={item.vatPercent} - /> - - onLineItemChange(i, 'total', v)} - value={item.total} - /> -
- -
-
- Subtotal - -
-
- VAT Amount - -
-
- Total -
- {total.formatted} {total.unit} -
-
-
- -

- This document is generated from on-chain Solana blockchain data. Editable fields (supplier info, items, - VAT) are provided for the user to complete manually. On-chain data (addresses, amounts, dates) is - pre-filled and verified against the blockchain. This receipt is not a tax invoice unless completed with - appropriate details. -

- -
- -
-
- ); -} - -function EditableInput({ - className, - onChange, - value, -}: { - className?: string; - onChange: (value: string) => void; - value: string; -}) { - return ( - onChange(e.target.value)} - value={value} - /> - ); -} diff --git a/app/features/receipt/ui/PrintableReceiptView.tsx b/app/features/receipt/ui/PrintableReceiptView.tsx deleted file mode 100644 index 821a8d4fc..000000000 --- a/app/features/receipt/ui/PrintableReceiptView.tsx +++ /dev/null @@ -1,105 +0,0 @@ -'use client'; - -import { useCallback, useRef, useState } from 'react'; -import { ArrowLeft, Image as ImageIcon, Printer } from 'react-feather'; - -import type { FormattedReceipt } from '../types'; -import { BasePrintableReceipt, type LineItem } from './BasePrintableReceipt'; - -interface PrintableReceiptViewProps { - data: FormattedReceipt & { - confirmationStatus?: string; - signature: string; - }; - onBack: () => void; -} - -const EMPTY_LINE_ITEM: LineItem = { description: '', qty: '', total: '', unitPrice: '', vatPercent: '' }; -const INITIAL_LINE_ITEMS: LineItem[] = Array.from({ length: 4 }, () => ({ ...EMPTY_LINE_ITEM })); - -export function PrintableReceiptView({ data, onBack }: PrintableReceiptViewProps) { - const [supplierName, setSupplierName] = useState(''); - const [supplierAddress, setSupplierAddress] = useState(''); - const [subtotal, setSubtotal] = useState(''); - const [vatAmount, setVatAmount] = useState(''); - const [lineItems, setLineItems] = useState(INITIAL_LINE_ITEMS); - const [logoDataUrl, setLogoDataUrl] = useState(); - const fileInputRef = useRef(null); - - const handleLogoUpload = useCallback((e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = () => { - setLogoDataUrl(reader.result as string); - }; - reader.readAsDataURL(file); - }, []); - - const handlePrint = useCallback(() => { - window.print(); - }, []); - - const handleLineItemChange = useCallback((index: number, field: keyof LineItem, value: string) => { - setLineItems(prev => prev.map((item, i) => (i === index ? { ...item, [field]: value } : item))); - }, []); - - return ( -
-
- - -
- - - -
-
- -
- -
-
- ); -} diff --git a/app/features/receipt/ui/stories/BasePrintableReceipt.stories.tsx b/app/features/receipt/ui/stories/BasePrintableReceipt.stories.tsx deleted file mode 100644 index bf9f02139..000000000 --- a/app/features/receipt/ui/stories/BasePrintableReceipt.stories.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; - -import { BasePrintableReceipt, type LineItem } from '../BasePrintableReceipt'; -import { - defaultReceipt, - receiptLargeAmount, - receiptTokenTransfer, - receiptWithDomains, - receiptWithMemo, -} from './receipt-fixtures'; - -const MOCK_SIGNATURE = '5UfDuX7h3bSd5gHSrKaJxYFc7RXvKqTnGjSGZzD3Rt1Xo2gPqAe7rGZtLtKgiXvBbXteu2MngGDPKzJHm6u8RBd'; - -const EMPTY_LINE_ITEM: LineItem = { description: '', qty: '', total: '', unitPrice: '', vatPercent: '' }; -const EMPTY_LINE_ITEMS: LineItem[] = Array.from({ length: 4 }, () => ({ ...EMPTY_LINE_ITEM })); - -function forPrintable(data: typeof defaultReceipt) { - return { - ...data, - confirmationStatus: 'finalized' as const, - signature: MOCK_SIGNATURE, - }; -} - -const noop = () => {}; - -const baseArgs = { - lineItems: EMPTY_LINE_ITEMS, - onLineItemChange: noop, - onSubtotalChange: noop, - onSupplierAddressChange: noop, - onSupplierNameChange: noop, - onVatAmountChange: noop, - subtotal: '', - supplierAddress: '', - supplierName: '', - vatAmount: '', -}; - -const meta: Meta = { - component: BasePrintableReceipt, - title: 'Features/Receipt/BasePrintableReceipt', -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - ...baseArgs, - data: forPrintable(defaultReceipt), - }, -}; - -export const WithMemo: Story = { - args: { - ...baseArgs, - data: forPrintable(receiptWithMemo), - }, -}; - -export const LargeAmount: Story = { - args: { - ...baseArgs, - data: forPrintable(receiptLargeAmount), - }, -}; - -export const WithDomainNames: Story = { - args: { - ...baseArgs, - data: forPrintable(receiptWithDomains), - }, -}; - -export const TokenTransfer: Story = { - args: { - ...baseArgs, - data: forPrintable(receiptTokenTransfer), - }, -}; - -export const WithFilledFields: Story = { - args: { - ...baseArgs, - data: forPrintable(defaultReceipt), - lineItems: [ - { description: 'Consulting services', qty: '10', total: '1000', unitPrice: '100', vatPercent: '20' }, - { description: 'Software license', qty: '1', total: '500', unitPrice: '500', vatPercent: '20' }, - { ...EMPTY_LINE_ITEM }, - { ...EMPTY_LINE_ITEM }, - ], - subtotal: '1500', - supplierAddress: '123 Main St, San Francisco, CA 94105', - supplierName: 'Acme Corp', - vatAmount: '300', - }, -}; From 2a3d3887b612d75dd4de671b92b1b93f0804176e Mon Sep 17 00:00:00 2001 From: Sergo Date: Thu, 5 Mar 2026 21:44:26 +0000 Subject: [PATCH 05/33] improve receipt generation --- .../__tests__/generate-receipt-pdf.test.ts | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/app/features/receipt/lib/__tests__/generate-receipt-pdf.test.ts b/app/features/receipt/lib/__tests__/generate-receipt-pdf.test.ts index 05e4486cf..757c6091c 100644 --- a/app/features/receipt/lib/__tests__/generate-receipt-pdf.test.ts +++ b/app/features/receipt/lib/__tests__/generate-receipt-pdf.test.ts @@ -64,20 +64,23 @@ const RECEIPT_URL = 'https://explorer.solana.com/receipt/5UfDuX7hXbGjGHqPXRGaHdS describe('generateReceiptPdf', () => { let generateReceiptPdf: typeof import('../generate-receipt-pdf').generateReceiptPdf; + let loadPdfDeps: typeof import('../generate-receipt-pdf').loadPdfDeps; beforeEach(async () => { vi.clearAllMocks(); - ({ generateReceiptPdf } = await import('../generate-receipt-pdf')); + ({ generateReceiptPdf, loadPdfDeps } = await import('../generate-receipt-pdf')); }); it('should create jsPDF instance with A4 format', async () => { const { jsPDF } = await import('jspdf'); - await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + const deps = await loadPdfDeps(); + await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); expect(jsPDF).toHaveBeenCalledWith({ format: 'a4', unit: 'mm' }); }); it('should add pre-filled text for all payment detail fields', async () => { - await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + const deps = await loadPdfDeps(); + await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); const textCalls = mockText.mock.calls.map(([text]: [string | string[]]) => Array.isArray(text) ? text.join(' ') : text @@ -94,7 +97,8 @@ describe('generateReceiptPdf', () => { }); it('should create AcroForm text fields for editable sections', async () => { - await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + const deps = await loadPdfDeps(); + await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); const fieldNames = mockTextField.mock.results .map(r => r.value.fieldName) @@ -102,12 +106,12 @@ describe('generateReceiptPdf', () => { expect(fieldNames).toContain('supplier_name'); expect(fieldNames).toContain('supplier_address'); - expect(fieldNames.some((n: string) => n.startsWith('item_0_'))).toBe(true); - expect(fieldNames.some((n: string) => n.startsWith('item_3_'))).toBe(true); + expect(fieldNames).toContain('items_description'); }); it('should render Total as static text, not an editable field', async () => { - await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + const deps = await loadPdfDeps(); + await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); const fieldNames = mockTextField.mock.results .map(r => r.value.fieldName) @@ -122,15 +126,17 @@ describe('generateReceiptPdf', () => { }); it('should call doc.save with full signature filename', async () => { - await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + const deps = await loadPdfDeps(); + await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); expect(mockSave).toHaveBeenCalledWith(`solana-receipt-${SIGNATURE}.pdf`); }); it('should handle missing memo gracefully', async () => { const receiptWithoutMemo: FormattedReceipt = { ...RECEIPT, memo: undefined }; + const deps = await loadPdfDeps(); - await generateReceiptPdf(receiptWithoutMemo, SIGNATURE, RECEIPT_URL); + await generateReceiptPdf(deps, receiptWithoutMemo, SIGNATURE, RECEIPT_URL); const textCalls = mockText.mock.calls.map(([text]: [string | string[]]) => Array.isArray(text) ? text.join(' ') : text @@ -142,14 +148,16 @@ describe('generateReceiptPdf', () => { }); it('should add editable fields via addField for each AcroForm field', async () => { - await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + const deps = await loadPdfDeps(); + await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); expect(mockAddField).toHaveBeenCalled(); expect(mockAddField.mock.calls.length).toBeGreaterThan(0); }); it('should include memo in text when present', async () => { - await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + const deps = await loadPdfDeps(); + await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); const textCalls = mockText.mock.calls.map(([text]: [string | string[]]) => Array.isArray(text) ? text.join(' ') : text @@ -161,9 +169,10 @@ describe('generateReceiptPdf', () => { }); it('should embed QR code image in the PDF', async () => { - await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + const deps = await loadPdfDeps(); + await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); - expect(mockToDataURL).toHaveBeenCalledWith(RECEIPT_URL, { width: 200, margin: 0 }); + expect(mockToDataURL).toHaveBeenCalledWith(RECEIPT_URL, { margin: 0, width: 200 }); const addImageCalls = mockAddImage.mock.calls; const qrCall = addImageCalls.find( @@ -180,8 +189,9 @@ describe('generateReceiptPdf', () => { it('should handle QR code generation failure gracefully', async () => { mockToDataURL.mockRejectedValueOnce(new Error('QR generation failed')); + const deps = await loadPdfDeps(); - await generateReceiptPdf(RECEIPT, SIGNATURE, RECEIPT_URL); + await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); expect(mockSave).toHaveBeenCalledWith(`solana-receipt-${SIGNATURE}.pdf`); }); From e6b61474bd1dcbd16315bbacbbc53f5457da8fb9 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Wed, 11 Mar 2026 17:42:06 +0100 Subject: [PATCH 06/33] updated pdf generation --- .../__tests__/generate-receipt-pdf.test.ts | 47 +++++++------------ 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/app/features/receipt/lib/__tests__/generate-receipt-pdf.test.ts b/app/features/receipt/lib/__tests__/generate-receipt-pdf.test.ts index 757c6091c..8c18cc133 100644 --- a/app/features/receipt/lib/__tests__/generate-receipt-pdf.test.ts +++ b/app/features/receipt/lib/__tests__/generate-receipt-pdf.test.ts @@ -25,8 +25,13 @@ const mockDoc = { }, addField: mockAddField, addImage: mockAddImage, + getFontSize: vi.fn().mockReturnValue(8), + getStringUnitWidth: vi.fn().mockReturnValue(0), + internal: { scaleFactor: 1 }, line: vi.fn(), + link: vi.fn(), rect: vi.fn(), + roundedRect: vi.fn(), save: mockSave, setDrawColor: vi.fn(), setFillColor: vi.fn(), @@ -82,13 +87,11 @@ describe('generateReceiptPdf', () => { const deps = await loadPdfDeps(); await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); - const textCalls = mockText.mock.calls.map(([text]: [string | string[]]) => - Array.isArray(text) ? text.join(' ') : text - ); + const textCalls = mockText.mock.calls.map(([text]) => (Array.isArray(text) ? text.join(' ') : text)); const allText = textCalls.join(' '); expect(allText).toContain('Solana Payment Receipt'); - expect(allText).toContain('Solana Blockchain'); + expect(allText).toContain('Solana (SOL)'); expect(allText).toContain('2023-11-14 22:13:20 UTC'); expect(allText).toContain('1.0 SOL'); expect(allText).toContain('SenderAddr111111111111111111111111111111111'); @@ -100,29 +103,23 @@ describe('generateReceiptPdf', () => { const deps = await loadPdfDeps(); await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); - const fieldNames = mockTextField.mock.results - .map(r => r.value.fieldName) - .filter(Boolean); + const fieldNames = mockTextField.mock.results.map(r => r.value.fieldName).filter(Boolean); expect(fieldNames).toContain('supplier_name'); expect(fieldNames).toContain('supplier_address'); expect(fieldNames).toContain('items_description'); }); - it('should render Total as static text, not an editable field', async () => { + it('should render Total as a pre-filled editable field', async () => { const deps = await loadPdfDeps(); await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); - const fieldNames = mockTextField.mock.results - .map(r => r.value.fieldName) - .filter(Boolean); - expect(fieldNames).not.toContain('total'); + const fieldNames = mockTextField.mock.results.map(r => r.value.fieldName).filter(Boolean); + expect(fieldNames).toContain('total'); - const textCalls = mockText.mock.calls.map(([text]: [string | string[]]) => - Array.isArray(text) ? text.join(' ') : text - ); + const textCalls = mockText.mock.calls.map(([text]) => (Array.isArray(text) ? text.join(' ') : text)); const allText = textCalls.join(' '); - expect(allText).toContain('1.0 SOL'); + expect(allText).toContain('TOTAL'); }); it('should call doc.save with full signature filename', async () => { @@ -138,9 +135,7 @@ describe('generateReceiptPdf', () => { await generateReceiptPdf(deps, receiptWithoutMemo, SIGNATURE, RECEIPT_URL); - const textCalls = mockText.mock.calls.map(([text]: [string | string[]]) => - Array.isArray(text) ? text.join(' ') : text - ); + const textCalls = mockText.mock.calls.map(([text]) => (Array.isArray(text) ? text.join(' ') : text)); const allText = textCalls.join(' '); expect(allText).not.toContain('Transaction Memo'); @@ -159,12 +154,10 @@ describe('generateReceiptPdf', () => { const deps = await loadPdfDeps(); await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); - const textCalls = mockText.mock.calls.map(([text]: [string | string[]]) => - Array.isArray(text) ? text.join(' ') : text - ); + const textCalls = mockText.mock.calls.map(([text]) => (Array.isArray(text) ? text.join(' ') : text)); const allText = textCalls.join(' '); - expect(allText).toContain('Transaction Memo'); + expect(allText).toContain('TRANSACTION MEMO'); expect(allText).toContain('Payment for services'); }); @@ -175,14 +168,10 @@ describe('generateReceiptPdf', () => { expect(mockToDataURL).toHaveBeenCalledWith(RECEIPT_URL, { margin: 0, width: 200 }); const addImageCalls = mockAddImage.mock.calls; - const qrCall = addImageCalls.find( - ([dataUrl]: [string]) => dataUrl === 'data:image/png;base64,qrcode' - ); + const qrCall = addImageCalls.find(([dataUrl]) => dataUrl === 'data:image/png;base64,qrcode'); expect(qrCall).toBeDefined(); - const textCalls = mockText.mock.calls.map(([text]: [string | string[]]) => - Array.isArray(text) ? text.join(' ') : text - ); + const textCalls = mockText.mock.calls.map(([text]) => (Array.isArray(text) ? text.join(' ') : text)); const allText = textCalls.join(' '); expect(allText).toContain('Verify on Solana Explorer'); }); From 817ba9d203b4c61be76e539f61915d2a77557356 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Thu, 12 Mar 2026 13:18:36 +0100 Subject: [PATCH 07/33] build fix + build info --- app/features/receipt/ui/stories/DownloadReceiptItem.stories.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/features/receipt/ui/stories/DownloadReceiptItem.stories.tsx b/app/features/receipt/ui/stories/DownloadReceiptItem.stories.tsx index 14a2bb684..338ab62ad 100644 --- a/app/features/receipt/ui/stories/DownloadReceiptItem.stories.tsx +++ b/app/features/receipt/ui/stories/DownloadReceiptItem.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import React from 'react'; import { Download } from 'react-feather'; import { expect, fn, userEvent, within } from 'storybook/test'; +import { vi } from 'vitest'; import { DownloadReceiptItem } from '../DownloadReceiptItem'; From 015c91bbbcaf28a97e85ecc46dff4f047e38fe2f Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Thu, 12 Mar 2026 14:22:56 +0100 Subject: [PATCH 08/33] resolve comments --- ...t.test.ts => use-download-receipt.test.ts} | 9 ++++- ...loadReceipt.ts => use-download-receipt.ts} | 5 +++ .../stories/DownloadReceiptItem.stories.tsx | 1 - app/utils/__tests__/formatUsdValue.test.ts | 39 +++++++++++++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) rename app/features/receipt/lib/__tests__/{useDownloadReceipt.test.ts => use-download-receipt.test.ts} (92%) rename app/features/receipt/lib/{useDownloadReceipt.ts => use-download-receipt.ts} (85%) create mode 100644 app/utils/__tests__/formatUsdValue.test.ts diff --git a/app/features/receipt/lib/__tests__/useDownloadReceipt.test.ts b/app/features/receipt/lib/__tests__/use-download-receipt.test.ts similarity index 92% rename from app/features/receipt/lib/__tests__/useDownloadReceipt.test.ts rename to app/features/receipt/lib/__tests__/use-download-receipt.test.ts index 03be89f83..0f9274a42 100644 --- a/app/features/receipt/lib/__tests__/useDownloadReceipt.test.ts +++ b/app/features/receipt/lib/__tests__/use-download-receipt.test.ts @@ -2,11 +2,11 @@ import { act, renderHook } from '@testing-library/react'; import { vi } from 'vitest'; describe('useDownloadReceipt', () => { - let useDownloadReceipt: typeof import('../useDownloadReceipt').useDownloadReceipt; + let useDownloadReceipt: typeof import('../use-download-receipt').useDownloadReceipt; beforeEach(async () => { vi.useFakeTimers(); - ({ useDownloadReceipt } = await import('../useDownloadReceipt')); + ({ useDownloadReceipt } = await import('../use-download-receipt')); }); afterEach(() => { @@ -140,6 +140,7 @@ describe('useDownloadReceipt', () => { }); it('should clean up timeout on unmount', async () => { + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); const download = vi.fn().mockResolvedValue(undefined); const { result, unmount } = renderHook(() => useDownloadReceipt(download, 1000)); @@ -147,8 +148,12 @@ describe('useDownloadReceipt', () => { result.current[1](); }); + expect(result.current[0]).toBe('downloaded'); + unmount(); + expect(clearTimeoutSpy).toHaveBeenCalled(); + act(() => { vi.advanceTimersByTime(1000); }); diff --git a/app/features/receipt/lib/useDownloadReceipt.ts b/app/features/receipt/lib/use-download-receipt.ts similarity index 85% rename from app/features/receipt/lib/useDownloadReceipt.ts rename to app/features/receipt/lib/use-download-receipt.ts index df46effaa..186156721 100644 --- a/app/features/receipt/lib/useDownloadReceipt.ts +++ b/app/features/receipt/lib/use-download-receipt.ts @@ -7,9 +7,12 @@ export type DownloadState = 'idle' | 'downloading' | 'downloaded' | 'errored'; export function useDownloadReceipt(download: DownloadReceiptFn, resetMs = 2000): readonly [DownloadState, () => void] { const [state, setState] = useState('idle'); const timeoutRef = useRef>(); + const mountedRef = useRef(true); useEffect(() => { + mountedRef.current = true; return () => { + mountedRef.current = false; clearTimeout(timeoutRef.current); }; }, []); @@ -26,10 +29,12 @@ export function useDownloadReceipt(download: DownloadReceiptFn, resetMs = 2000): download().then( () => { + if (!mountedRef.current) return; setState('downloaded'); scheduleReset(); }, (error: unknown) => { + if (!mountedRef.current) return; console.error('Download failed:', error); setState('errored'); scheduleReset(); diff --git a/app/features/receipt/ui/stories/DownloadReceiptItem.stories.tsx b/app/features/receipt/ui/stories/DownloadReceiptItem.stories.tsx index 338ab62ad..14a2bb684 100644 --- a/app/features/receipt/ui/stories/DownloadReceiptItem.stories.tsx +++ b/app/features/receipt/ui/stories/DownloadReceiptItem.stories.tsx @@ -2,7 +2,6 @@ import type { Meta, StoryObj } from '@storybook/react'; import React from 'react'; import { Download } from 'react-feather'; import { expect, fn, userEvent, within } from 'storybook/test'; -import { vi } from 'vitest'; import { DownloadReceiptItem } from '../DownloadReceiptItem'; diff --git a/app/utils/__tests__/formatUsdValue.test.ts b/app/utils/__tests__/formatUsdValue.test.ts new file mode 100644 index 000000000..42a17eec6 --- /dev/null +++ b/app/utils/__tests__/formatUsdValue.test.ts @@ -0,0 +1,39 @@ +import { formatUsdValue } from '@utils/index'; + +describe('formatUsdValue', () => { + it('formats a normal value', () => { + expect(formatUsdValue(1, 200)).toBe('$200.00'); + }); + + it('formats fractional result with 2 decimal places', () => { + expect(formatUsdValue(0.5, 3)).toBe('$1.50'); + }); + + it('adds thousands separator', () => { + expect(formatUsdValue(10, 1000)).toBe('$10,000.00'); + }); + + it('returns $0.00 for zero amount', () => { + expect(formatUsdValue(0, 200)).toBe('$0.00'); + }); + + it('returns $0.00 for zero price', () => { + expect(formatUsdValue(1, 0)).toBe('$0.00'); + }); + + it('returns $0.00 for NaN amount', () => { + expect(formatUsdValue(NaN, 200)).toBe('$0.00'); + }); + + it('returns $0.00 for NaN price', () => { + expect(formatUsdValue(1, NaN)).toBe('$0.00'); + }); + + it('returns $0.00 for negative amount', () => { + expect(formatUsdValue(-1, 200)).toBe('$0.00'); + }); + + it('returns $0.00 for negative price', () => { + expect(formatUsdValue(1, -200)).toBe('$0.00'); + }); +}); From 2ab1047457eca9dae062dd40f5938b75144b7078 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Sat, 14 Mar 2026 00:13:18 +0100 Subject: [PATCH 09/33] resolve comments --- .../receipt/lib/use-download-receipt.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app/features/receipt/lib/use-download-receipt.ts b/app/features/receipt/lib/use-download-receipt.ts index 186156721..097bbb2b3 100644 --- a/app/features/receipt/lib/use-download-receipt.ts +++ b/app/features/receipt/lib/use-download-receipt.ts @@ -1,11 +1,14 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import Logger from '@/app/utils/logger'; + import type { DownloadReceiptFn } from '../types'; export type DownloadState = 'idle' | 'downloading' | 'downloaded' | 'errored'; export function useDownloadReceipt(download: DownloadReceiptFn, resetMs = 2000): readonly [DownloadState, () => void] { const [state, setState] = useState('idle'); + const stateRef = useRef('idle'); const timeoutRef = useRef>(); const mountedRef = useRef(true); @@ -18,29 +21,35 @@ export function useDownloadReceipt(download: DownloadReceiptFn, resetMs = 2000): }, []); const scheduleReset = useCallback(() => { - timeoutRef.current = setTimeout(() => setState('idle'), resetMs); + timeoutRef.current = setTimeout(() => { + stateRef.current = 'idle'; + setState('idle'); + }, resetMs); }, [resetMs]); const trigger = useCallback(() => { - if (state === 'downloading') return; + if (stateRef.current === 'downloading') return; clearTimeout(timeoutRef.current); + stateRef.current = 'downloading'; setState('downloading'); download().then( () => { if (!mountedRef.current) return; + stateRef.current = 'downloaded'; setState('downloaded'); scheduleReset(); }, (error: unknown) => { if (!mountedRef.current) return; - console.error('Download failed:', error); + Logger.error('Download failed:', error); + stateRef.current = 'errored'; setState('errored'); scheduleReset(); } ); - }, [state, download, scheduleReset]); + }, [download, scheduleReset]); return [state, trigger] as const; } From 553b0b565c243663eae4cff85bcb126a1a0288dc Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Mon, 16 Mar 2026 15:20:12 +0100 Subject: [PATCH 10/33] csv download --- .../__tests__/generate-receipt-csv.test.ts | 131 ++++++++++++++++++ .../receipt/lib/generate-receipt-csv.ts | 65 +++++++++ app/features/receipt/receipt-page.tsx | 8 +- app/features/receipt/ui/ReceiptView.tsx | 6 +- .../ui/stories/ReceiptView.stories.tsx | 24 +++- 5 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 app/features/receipt/lib/__tests__/generate-receipt-csv.test.ts create mode 100644 app/features/receipt/lib/generate-receipt-csv.ts diff --git a/app/features/receipt/lib/__tests__/generate-receipt-csv.test.ts b/app/features/receipt/lib/__tests__/generate-receipt-csv.test.ts new file mode 100644 index 000000000..0594fbabe --- /dev/null +++ b/app/features/receipt/lib/__tests__/generate-receipt-csv.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { FormattedReceipt } from '../../types'; +import { buildReceiptCsvRow, generateReceiptCsv } from '../generate-receipt-csv'; + +const RECEIPT: FormattedReceipt = { + date: { timestamp: 1700000000, utc: '2023-11-14 22:13:20 UTC' }, + fee: { formatted: '0.000005', raw: 5000 }, + memo: 'Payment for services', + network: 'mainnet-beta', + receiver: { address: 'ReceiverAddr2222222222222222222222222222222', truncated: 'Recv...2222' }, + sender: { address: 'SenderAddr111111111111111111111111111111111', truncated: 'Send...1111' }, + total: { formatted: '1.0', raw: 1000000000, unit: 'SOL' }, +}; + +const SIGNATURE = '5UfDuX7hXbGjGHqPXRGaHdSecretSignature1234567890abcdef'; + +describe('buildReceiptCsvRow', () => { + it('should include all expected fields in correct column order', () => { + const row = buildReceiptCsvRow(RECEIPT, SIGNATURE); + const fields = row.split(','); + + expect(fields[0]).toBe('2023-11-14 22:13:20 UTC'); + expect(fields[1]).toBe(SIGNATURE); + expect(fields[2]).toBe('mainnet-beta'); + expect(fields[3]).toBe('SenderAddr111111111111111111111111111111111'); + expect(fields[4]).toBe('ReceiverAddr2222222222222222222222222222222'); + expect(fields[5]).toBe('1.0'); + expect(fields[6]).toBe('SOL'); + expect(fields[7]).toBe(''); + expect(fields[8]).toBe(''); + expect(fields[9]).toBe('0.000005'); + expect(fields[10]).toBe('Payment for services'); + expect(fields).toHaveLength(11); + }); + + it('should include USD value when provided', () => { + const row = buildReceiptCsvRow(RECEIPT, SIGNATURE, '$150.00'); + const fields = row.split(','); + expect(fields[8]).toBe('$150.00'); + }); + + it('should include mint address for token receipts', () => { + const tokenReceipt: FormattedReceipt = { + ...RECEIPT, + mint: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + symbol: 'USDC', + total: { formatted: '143.25', raw: 143.25, unit: 'USDC' }, + }; + const row = buildReceiptCsvRow(tokenReceipt, SIGNATURE); + const fields = row.split(','); + expect(fields[6]).toBe('USDC'); + expect(fields[7]).toBe('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'); + }); + + it('should leave memo field empty when absent', () => { + const receiptNoMemo: FormattedReceipt = { ...RECEIPT, memo: undefined }; + const row = buildReceiptCsvRow(receiptNoMemo, SIGNATURE); + expect(row.endsWith(',')).toBe(true); + }); + + it('should quote values containing commas', () => { + const receiptWithComma: FormattedReceipt = { ...RECEIPT, memo: 'Payment, for services' }; + const row = buildReceiptCsvRow(receiptWithComma, SIGNATURE); + expect(row).toContain('"Payment, for services"'); + }); + + it('should escape double quotes inside quoted values', () => { + const receiptWithQuote: FormattedReceipt = { ...RECEIPT, memo: 'Payment "urgent"' }; + const row = buildReceiptCsvRow(receiptWithQuote, SIGNATURE); + expect(row).toContain('"Payment ""urgent"""'); + }); + + it('should quote values containing newlines', () => { + const receiptWithNewline: FormattedReceipt = { ...RECEIPT, memo: 'Line1\nLine2' }; + const row = buildReceiptCsvRow(receiptWithNewline, SIGNATURE); + expect(row).toContain('"Line1\nLine2"'); + }); +}); + +describe('generateReceiptCsv', () => { + let mockClick: ReturnType; + let linkElement: Record; + + beforeEach(() => { + mockClick = vi.fn(); + linkElement = { click: mockClick, download: '', href: '' }; + vi.spyOn(document, 'createElement').mockReturnValue(linkElement as unknown as HTMLElement); + vi.spyOn(document.body, 'appendChild').mockReturnValue(linkElement as unknown as ChildNode); + vi.spyOn(document.body, 'removeChild').mockReturnValue(linkElement as unknown as ChildNode); + vi.stubGlobal('URL', { + createObjectURL: vi.fn().mockReturnValue('blob:test-url'), + revokeObjectURL: vi.fn(), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('should set the correct download filename', () => { + generateReceiptCsv(RECEIPT, SIGNATURE); + expect(linkElement.download).toBe(`solana-receipt-${SIGNATURE}.csv`); + }); + + it('should set the href to the object URL', () => { + generateReceiptCsv(RECEIPT, SIGNATURE); + expect(linkElement.href).toBe('blob:test-url'); + }); + + it('should revoke the object URL after triggering download', () => { + generateReceiptCsv(RECEIPT, SIGNATURE); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:test-url'); + }); + + it('should pass a Blob with CSV mime type to createObjectURL', () => { + generateReceiptCsv(RECEIPT, SIGNATURE); + // eslint-disable-next-line no-restricted-syntax -- accessing vitest mock internals for assertion + const blobArg = (URL.createObjectURL as ReturnType).mock.calls[0][0] as Blob; + expect(blobArg).toBeInstanceOf(Blob); + expect(blobArg.type).toBe('text/csv;charset=utf-8;'); + }); + + it('should pass a non-empty Blob to createObjectURL', () => { + generateReceiptCsv(RECEIPT, SIGNATURE); + // eslint-disable-next-line no-restricted-syntax -- accessing vitest mock internals for assertion + const blobArg = (URL.createObjectURL as ReturnType).mock.calls[0][0] as Blob; + expect(blobArg.size).toBeGreaterThan(0); + }); +}); diff --git a/app/features/receipt/lib/generate-receipt-csv.ts b/app/features/receipt/lib/generate-receipt-csv.ts new file mode 100644 index 000000000..c1caa1d54 --- /dev/null +++ b/app/features/receipt/lib/generate-receipt-csv.ts @@ -0,0 +1,65 @@ +import type { FormattedReceipt } from '../types'; + +const CSV_HEADERS = [ + 'Date (UTC)', + 'Signature', + 'Network', + 'Sender', + 'Receiver', + 'Amount', + 'Token', + 'Mint', + 'Amount (USD)', + 'Fee (SOL)', + 'Memo', +] as const; + +function needsQuoting(value: string): boolean { + // eslint-disable-next-line no-restricted-syntax -- regex is needed to detect CSV special characters + return /[",\n\r]/.test(value); +} + +function csvEscape(value: string | undefined): string { + if (!value) return ''; + if (needsQuoting(value)) { + // eslint-disable-next-line no-restricted-syntax -- regex is needed to escape double quotes per RFC 4180 + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +export function buildReceiptCsvRow(receipt: FormattedReceipt, signature: string, usdValue?: string): string { + const mint = 'mint' in receipt ? receipt.mint : undefined; + + const fields: (string | undefined)[] = [ + receipt.date.utc, + signature, + receipt.network, + receipt.sender.address, + receipt.receiver.address, + receipt.total.formatted, + receipt.total.unit, + mint, + usdValue, + receipt.fee.formatted, + receipt.memo, + ]; + + return fields.map(f => csvEscape(f)).join(','); +} + +export function generateReceiptCsv(receipt: FormattedReceipt, signature: string, usdValue?: string): void { + const header = CSV_HEADERS.join(','); + const row = buildReceiptCsvRow(receipt, signature, usdValue); + const csv = `${header}\n${row}`; + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `solana-receipt-${signature}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} diff --git a/app/features/receipt/receipt-page.tsx b/app/features/receipt/receipt-page.tsx index af5e54435..4f54e4f18 100644 --- a/app/features/receipt/receipt-page.tsx +++ b/app/features/receipt/receipt-page.tsx @@ -22,6 +22,7 @@ import { receiptAnalytics } from '@/app/shared/lib/analytics'; import { Logger } from '@/app/shared/lib/logger'; import { AUTO_REFRESH_INTERVAL, AutoRefresh, type AutoRefreshProps } from '@/app/tx/[signature]/page-client'; +import { generateReceiptCsv } from './lib/generate-receipt-csv'; import { generateReceiptPdf, loadPdfDeps } from './lib/generate-receipt-pdf'; import { usePrimaryDomain } from './lib/use-primary-domain'; import { extractReceiptData } from './model/create-receipt'; @@ -150,6 +151,10 @@ function ReceiptContent({ receipt, signature, status, transactionPath }: Receipt const amount = receipt.kind === 'sol' ? lamportsToSol(receipt.total.raw) : receipt.total.raw; const usdValue = priceResult?.price != null ? formatUsdValue(amount, priceResult.price) : undefined; + const downloadCsv = useCallback(async () => { + generateReceiptCsv(receipt, signature, usdValue); + }, [receipt, signature, usdValue]); + const downloadPdf = useCallback(async () => { const deps = await loadPdfDeps(); const transactionUrl = window.location.origin + transactionPath; @@ -176,9 +181,10 @@ function ReceiptContent({ receipt, signature, status, transactionPath }: Receipt senderHref: senderLink.link, tokenHref: tokenLink.link, }} + downloadCsv={downloadCsv} + downloadPdf={downloadPdf} signature={signature} transactionPath={transactionPath} - downloadPdf={downloadPdf} /> ); diff --git a/app/features/receipt/ui/ReceiptView.tsx b/app/features/receipt/ui/ReceiptView.tsx index 0b7a90378..b6403da63 100644 --- a/app/features/receipt/ui/ReceiptView.tsx +++ b/app/features/receipt/ui/ReceiptView.tsx @@ -15,12 +15,13 @@ import { PopoverButton } from './PopoverButton'; interface ReceiptViewProps { data: FormattedExtendedReceipt; + downloadCsv: DownloadReceiptFn; + downloadPdf: DownloadReceiptFn; signature: TransactionSignature; transactionPath: string; - downloadPdf: DownloadReceiptFn; } -export function ReceiptView({ data, signature, transactionPath, downloadPdf }: ReceiptViewProps) { +export function ReceiptView({ data, downloadCsv, downloadPdf, signature, transactionPath }: ReceiptViewProps) { function handleViewTxClick() { receiptAnalytics.trackViewTxClicked(signature); } @@ -49,6 +50,7 @@ export function ReceiptView({ data, signature, transactionPath, downloadPdf }: R
} label="Download"> +
diff --git a/app/features/receipt/ui/stories/ReceiptView.stories.tsx b/app/features/receipt/ui/stories/ReceiptView.stories.tsx index 225da1f71..af2dad405 100644 --- a/app/features/receipt/ui/stories/ReceiptView.stories.tsx +++ b/app/features/receipt/ui/stories/ReceiptView.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { expect, fn, within } from 'storybook/test'; +import { expect, fn, userEvent, within } from 'storybook/test'; import { ReceiptView } from '../ReceiptView'; import { @@ -12,6 +12,7 @@ import { const meta: Meta = { args: { + downloadCsv: fn().mockResolvedValue(undefined), downloadPdf: fn().mockResolvedValue(undefined), signature: 'ExampleTransactionSignature', transactionPath: 'https://example.com/tx/ExampleTransactionSignature', @@ -37,6 +38,27 @@ export const Default: Story = { }, }; +export const DownloadOptions: Story = { + args: { + data: forBaseReceipt(defaultReceipt), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // eslint-disable-next-line no-restricted-syntax -- case-insensitive accessible name match for testing-library query + const downloadButton = canvas.getByRole('button', { name: /download/i }); + await userEvent.click(downloadButton); + + // eslint-disable-next-line no-restricted-syntax -- case-insensitive accessible name match for testing-library query + const csvButton = await within(document.body).findByRole('button', { name: /^csv$/i }); + await expect(csvButton).toBeInTheDocument(); + + // eslint-disable-next-line no-restricted-syntax -- case-insensitive accessible name match for testing-library query + const pdfButton = await within(document.body).findByRole('button', { name: /^pdf$/i }); + await expect(pdfButton).toBeInTheDocument(); + }, +}; + export const WithMemo: Story = { args: { data: forBaseReceipt(receiptWithMemo), From 7368657678b1d50ee0d5ab09871fc30f63fe9f10 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Tue, 17 Mar 2026 14:57:47 +0100 Subject: [PATCH 11/33] added fast-csv lib --- .../__tests__/generate-receipt-csv.test.ts | 75 +++++++------------ .../receipt/lib/generate-receipt-csv.ts | 37 ++++----- app/features/receipt/receipt-page.tsx | 2 +- package.json | 1 + pnpm-lock.yaml | 69 +++++++++++++++++ 5 files changed, 111 insertions(+), 73 deletions(-) diff --git a/app/features/receipt/lib/__tests__/generate-receipt-csv.test.ts b/app/features/receipt/lib/__tests__/generate-receipt-csv.test.ts index 0594fbabe..6d15c8e0d 100644 --- a/app/features/receipt/lib/__tests__/generate-receipt-csv.test.ts +++ b/app/features/receipt/lib/__tests__/generate-receipt-csv.test.ts @@ -18,26 +18,24 @@ const SIGNATURE = '5UfDuX7hXbGjGHqPXRGaHdSecretSignature1234567890abcdef'; describe('buildReceiptCsvRow', () => { it('should include all expected fields in correct column order', () => { const row = buildReceiptCsvRow(RECEIPT, SIGNATURE); - const fields = row.split(','); - - expect(fields[0]).toBe('2023-11-14 22:13:20 UTC'); - expect(fields[1]).toBe(SIGNATURE); - expect(fields[2]).toBe('mainnet-beta'); - expect(fields[3]).toBe('SenderAddr111111111111111111111111111111111'); - expect(fields[4]).toBe('ReceiverAddr2222222222222222222222222222222'); - expect(fields[5]).toBe('1.0'); - expect(fields[6]).toBe('SOL'); - expect(fields[7]).toBe(''); - expect(fields[8]).toBe(''); - expect(fields[9]).toBe('0.000005'); - expect(fields[10]).toBe('Payment for services'); - expect(fields).toHaveLength(11); + + expect(row[0]).toBe('2023-11-14 22:13:20 UTC'); + expect(row[1]).toBe(SIGNATURE); + expect(row[2]).toBe('mainnet-beta'); + expect(row[3]).toBe('SenderAddr111111111111111111111111111111111'); + expect(row[4]).toBe('ReceiverAddr2222222222222222222222222222222'); + expect(row[5]).toBe('1.0'); + expect(row[6]).toBe('SOL'); + expect(row[7]).toBe(''); + expect(row[8]).toBe(''); + expect(row[9]).toBe('0.000005'); + expect(row[10]).toBe('Payment for services'); + expect(row).toHaveLength(11); }); it('should include USD value when provided', () => { const row = buildReceiptCsvRow(RECEIPT, SIGNATURE, '$150.00'); - const fields = row.split(','); - expect(fields[8]).toBe('$150.00'); + expect(row[8]).toBe('$150.00'); }); it('should include mint address for token receipts', () => { @@ -48,33 +46,14 @@ describe('buildReceiptCsvRow', () => { total: { formatted: '143.25', raw: 143.25, unit: 'USDC' }, }; const row = buildReceiptCsvRow(tokenReceipt, SIGNATURE); - const fields = row.split(','); - expect(fields[6]).toBe('USDC'); - expect(fields[7]).toBe('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'); + expect(row[6]).toBe('USDC'); + expect(row[7]).toBe('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'); }); it('should leave memo field empty when absent', () => { const receiptNoMemo: FormattedReceipt = { ...RECEIPT, memo: undefined }; const row = buildReceiptCsvRow(receiptNoMemo, SIGNATURE); - expect(row.endsWith(',')).toBe(true); - }); - - it('should quote values containing commas', () => { - const receiptWithComma: FormattedReceipt = { ...RECEIPT, memo: 'Payment, for services' }; - const row = buildReceiptCsvRow(receiptWithComma, SIGNATURE); - expect(row).toContain('"Payment, for services"'); - }); - - it('should escape double quotes inside quoted values', () => { - const receiptWithQuote: FormattedReceipt = { ...RECEIPT, memo: 'Payment "urgent"' }; - const row = buildReceiptCsvRow(receiptWithQuote, SIGNATURE); - expect(row).toContain('"Payment ""urgent"""'); - }); - - it('should quote values containing newlines', () => { - const receiptWithNewline: FormattedReceipt = { ...RECEIPT, memo: 'Line1\nLine2' }; - const row = buildReceiptCsvRow(receiptWithNewline, SIGNATURE); - expect(row).toContain('"Line1\nLine2"'); + expect(row[10]).toBe(''); }); }); @@ -99,31 +78,31 @@ describe('generateReceiptCsv', () => { vi.restoreAllMocks(); }); - it('should set the correct download filename', () => { - generateReceiptCsv(RECEIPT, SIGNATURE); + it('should set the correct download filename', async () => { + await generateReceiptCsv(RECEIPT, SIGNATURE); expect(linkElement.download).toBe(`solana-receipt-${SIGNATURE}.csv`); }); - it('should set the href to the object URL', () => { - generateReceiptCsv(RECEIPT, SIGNATURE); + it('should set the href to the object URL', async () => { + await generateReceiptCsv(RECEIPT, SIGNATURE); expect(linkElement.href).toBe('blob:test-url'); }); - it('should revoke the object URL after triggering download', () => { - generateReceiptCsv(RECEIPT, SIGNATURE); + it('should revoke the object URL after triggering download', async () => { + await generateReceiptCsv(RECEIPT, SIGNATURE); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:test-url'); }); - it('should pass a Blob with CSV mime type to createObjectURL', () => { - generateReceiptCsv(RECEIPT, SIGNATURE); + it('should pass a Blob with CSV mime type to createObjectURL', async () => { + await generateReceiptCsv(RECEIPT, SIGNATURE); // eslint-disable-next-line no-restricted-syntax -- accessing vitest mock internals for assertion const blobArg = (URL.createObjectURL as ReturnType).mock.calls[0][0] as Blob; expect(blobArg).toBeInstanceOf(Blob); expect(blobArg.type).toBe('text/csv;charset=utf-8;'); }); - it('should pass a non-empty Blob to createObjectURL', () => { - generateReceiptCsv(RECEIPT, SIGNATURE); + it('should pass a non-empty Blob to createObjectURL', async () => { + await generateReceiptCsv(RECEIPT, SIGNATURE); // eslint-disable-next-line no-restricted-syntax -- accessing vitest mock internals for assertion const blobArg = (URL.createObjectURL as ReturnType).mock.calls[0][0] as Blob; expect(blobArg.size).toBeGreaterThan(0); diff --git a/app/features/receipt/lib/generate-receipt-csv.ts b/app/features/receipt/lib/generate-receipt-csv.ts index c1caa1d54..af6281103 100644 --- a/app/features/receipt/lib/generate-receipt-csv.ts +++ b/app/features/receipt/lib/generate-receipt-csv.ts @@ -1,3 +1,5 @@ +import { writeToString } from 'fast-csv'; + import type { FormattedReceipt } from '../types'; const CSV_HEADERS = [ @@ -14,24 +16,10 @@ const CSV_HEADERS = [ 'Memo', ] as const; -function needsQuoting(value: string): boolean { - // eslint-disable-next-line no-restricted-syntax -- regex is needed to detect CSV special characters - return /[",\n\r]/.test(value); -} - -function csvEscape(value: string | undefined): string { - if (!value) return ''; - if (needsQuoting(value)) { - // eslint-disable-next-line no-restricted-syntax -- regex is needed to escape double quotes per RFC 4180 - return `"${value.replace(/"/g, '""')}"`; - } - return value; -} - -export function buildReceiptCsvRow(receipt: FormattedReceipt, signature: string, usdValue?: string): string { +export function buildReceiptCsvRow(receipt: FormattedReceipt, signature: string, usdValue?: string): string[] { const mint = 'mint' in receipt ? receipt.mint : undefined; - const fields: (string | undefined)[] = [ + return [ receipt.date.utc, signature, receipt.network, @@ -39,19 +27,20 @@ export function buildReceiptCsvRow(receipt: FormattedReceipt, signature: string, receipt.receiver.address, receipt.total.formatted, receipt.total.unit, - mint, - usdValue, + mint ?? '', + usdValue ?? '', receipt.fee.formatted, - receipt.memo, + receipt.memo ?? '', ]; - - return fields.map(f => csvEscape(f)).join(','); } -export function generateReceiptCsv(receipt: FormattedReceipt, signature: string, usdValue?: string): void { - const header = CSV_HEADERS.join(','); +export async function generateReceiptCsv( + receipt: FormattedReceipt, + signature: string, + usdValue?: string +): Promise { const row = buildReceiptCsvRow(receipt, signature, usdValue); - const csv = `${header}\n${row}`; + const csv = await writeToString([row], { headers: [...CSV_HEADERS] }); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); diff --git a/app/features/receipt/receipt-page.tsx b/app/features/receipt/receipt-page.tsx index 4f54e4f18..29a7dbe15 100644 --- a/app/features/receipt/receipt-page.tsx +++ b/app/features/receipt/receipt-page.tsx @@ -152,7 +152,7 @@ function ReceiptContent({ receipt, signature, status, transactionPath }: Receipt const usdValue = priceResult?.price != null ? formatUsdValue(amount, priceResult.price) : undefined; const downloadCsv = useCallback(async () => { - generateReceiptCsv(receipt, signature, usdValue); + await generateReceiptCsv(receipt, signature, usdValue); }, [receipt, signature, usdValue]); const downloadPdf = useCallback(async () => { diff --git a/package.json b/package.json index 9ab31e966..cdffd29b3 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "codama": "1.2.11", "cross-fetch": "3.2.0", "cross-spawn": "7.0.6", + "fast-csv": "^5.0.5", "fuse.js": "7.1.0", "humanize-duration-ts": "2.1.1", "ipaddr.js": "2.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c5ad6964..d3a223eb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,6 +231,9 @@ importers: cross-spawn: specifier: 7.0.6 version: 7.0.6 + fast-csv: + specifier: ^5.0.5 + version: 5.0.5 fuse.js: specifier: 7.1.0 version: 7.1.0 @@ -1589,6 +1592,12 @@ packages: '@ethersproject/wordlists@5.7.0': resolution: {integrity: sha512-S2TFNJNfHWVHNE6cNDjbVlZ6MgE17MIxMbMg2zv3wn+3XSJGosL1m9ZVv3GXCf/2ymSsQ+hRI5IzoMJTG6aoVA==} + '@fast-csv/format@5.0.5': + resolution: {integrity: sha512-0P9SJXXnqKdmuWlLaTelqbrfdgN37Mvrb369J6eNmqL41IEIZQmV4sNM4GgAK2Dz3aH04J0HKGDMJFkYObThTw==} + + '@fast-csv/parse@5.0.5': + resolution: {integrity: sha512-M0IbaXZDbxfOnpVE5Kps/a6FGlILLhtLsvWd9qNH3d2TxNnpbNkFf3KD26OmJX6MHq7PdQAl5htStDwnuwHx6w==} + '@floating-ui/core@1.6.9': resolution: {integrity: sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==} @@ -6768,6 +6777,10 @@ packages: resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} engines: {node: '> 0.1.90'} + fast-csv@5.0.5: + resolution: {integrity: sha512-9//QpogDIPln5Dc8e3Q3vbSSLXlTeU7z1JqsUOXZYOln8EIn/OOO8+NS2c3ukR6oYngDd3+P1HXSkby3kNV9KA==} + engines: {node: '>=10.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -7953,15 +7966,36 @@ packages: lodash.curry@4.1.1: resolution: {integrity: sha512-/u14pXGviLaweY5JI0IUzgzF2J6Ne8INyzAZjImcryjgkZ+ebruBxy2/JaOOkTqScddcYtakjhSaeemV8lR0tA==} + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.flow@3.5.0: resolution: {integrity: sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw==} + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} lodash.throttle@4.1.1: resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -12251,6 +12285,22 @@ snapshots: '@ethersproject/properties': 5.7.0 '@ethersproject/strings': 5.7.0 + '@fast-csv/format@5.0.5': + dependencies: + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@5.0.5': + dependencies: + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + '@floating-ui/core@1.6.9': dependencies: '@floating-ui/utils': 0.2.9 @@ -19110,6 +19160,11 @@ snapshots: eyes@0.1.8: {} + fast-csv@5.0.5: + dependencies: + '@fast-csv/format': 5.0.5 + '@fast-csv/parse': 5.0.5 + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -20460,12 +20515,26 @@ snapshots: lodash.curry@4.1.1: {} + lodash.escaperegexp@4.1.2: {} + lodash.flow@3.5.0: {} + lodash.groupby@4.6.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isfunction@3.0.9: {} + + lodash.isnil@4.0.0: {} + + lodash.isundefined@3.0.1: {} + lodash.merge@4.6.2: {} lodash.throttle@4.1.1: {} + lodash.uniq@4.5.0: {} + lodash@4.17.21: {} log-symbols@6.0.0: From 5e50311790849f8225ac1fb4333da6bdeecefa04 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Mon, 23 Mar 2026 23:09:48 +0100 Subject: [PATCH 12/33] fixed logger --- app/features/receipt/lib/use-download-receipt.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/features/receipt/lib/use-download-receipt.ts b/app/features/receipt/lib/use-download-receipt.ts index 097bbb2b3..1a499758c 100644 --- a/app/features/receipt/lib/use-download-receipt.ts +++ b/app/features/receipt/lib/use-download-receipt.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import Logger from '@/app/utils/logger'; +import { Logger } from '@/app/shared/lib/logger'; import type { DownloadReceiptFn } from '../types'; @@ -43,7 +43,7 @@ export function useDownloadReceipt(download: DownloadReceiptFn, resetMs = 2000): }, (error: unknown) => { if (!mountedRef.current) return; - Logger.error('Download failed:', error); + Logger.error(error); stateRef.current = 'errored'; setState('errored'); scheduleReset(); From d839f40338047821ba3454f9313495875d76ff87 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Mon, 23 Mar 2026 23:21:53 +0100 Subject: [PATCH 13/33] fix merge --- app/features/receipt/__e2e__/receipt.e2e.ts | 63 +++++- .../__tests__/generate-receipt-pdf.test.ts | 187 ------------------ .../__tests__/normalize-search-params.test.ts | 120 ----------- .../__tests__/use-download-receipt.test.ts | 161 --------------- .../receipt/lib/use-download-receipt.ts | 55 ------ app/utils/__tests__/formatUsdValue.test.ts | 39 ---- 6 files changed, 62 insertions(+), 563 deletions(-) delete mode 100644 app/features/receipt/lib/__tests__/generate-receipt-pdf.test.ts delete mode 100644 app/features/receipt/lib/__tests__/normalize-search-params.test.ts delete mode 100644 app/features/receipt/lib/__tests__/use-download-receipt.test.ts delete mode 100644 app/features/receipt/lib/use-download-receipt.ts delete mode 100644 app/utils/__tests__/formatUsdValue.test.ts diff --git a/app/features/receipt/__e2e__/receipt.e2e.ts b/app/features/receipt/__e2e__/receipt.e2e.ts index 6ee08557e..82daba78f 100644 --- a/app/features/receipt/__e2e__/receipt.e2e.ts +++ b/app/features/receipt/__e2e__/receipt.e2e.ts @@ -81,7 +81,7 @@ test.describe('when feature enabled', () => { text.includes('Error') ); }, - { timeout: CONTENT_TIMEOUT }, + { timeout: CONTENT_TIMEOUT } ); const text = await page.textContent('body'); @@ -93,6 +93,67 @@ test.describe('when feature enabled', () => { expect(showsError).toBe(true); }); + test('shows CSV and PDF options in download menu', async ({ page }) => { + await waitForPage(page, VALID_TX, 'receipt'); + + await page + .locator('h3:has-text("Solana Receipt")') + .or(page.locator('text=Not Found')) + .or(page.locator('text=Receipts can only be generated')) + .first() + .waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT }); + + const hasReceipt = await hasElement(page, 'h3:has-text("Solana Receipt")'); + if (!hasReceipt) return; + + await page.locator('button:has-text("Download")').click(); + await expect(page.locator('button:has-text("CSV")')).toBeVisible({ timeout: CONTENT_TIMEOUT }); + await expect(page.locator('button:has-text("PDF")')).toBeVisible({ timeout: CONTENT_TIMEOUT }); + }); + + test('triggers CSV download with correct filename', async ({ page }) => { + await waitForPage(page, VALID_TX, 'receipt'); + + await page + .locator('h3:has-text("Solana Receipt")') + .or(page.locator('text=Not Found')) + .or(page.locator('text=Receipts can only be generated')) + .first() + .waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT }); + + const hasReceipt = await hasElement(page, 'h3:has-text("Solana Receipt")'); + if (!hasReceipt) return; + + await page.locator('button:has-text("Download")').click(); + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.locator('button:has-text("CSV")').click(), + ]); + + // eslint-disable-next-line no-restricted-syntax -- regex needed to validate filename pattern: solana-receipt-.csv + expect(download.suggestedFilename()).toMatch(/^solana-receipt-.+\.csv$/); + }); + + test('shows Downloaded! state after CSV download completes', async ({ page }) => { + await waitForPage(page, VALID_TX, 'receipt'); + + await page + .locator('h3:has-text("Solana Receipt")') + .or(page.locator('text=Not Found')) + .or(page.locator('text=Receipts can only be generated')) + .first() + .waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT }); + + const hasReceipt = await hasElement(page, 'h3:has-text("Solana Receipt")'); + if (!hasReceipt) return; + + await page.locator('button:has-text("Download")').click(); + await page.locator('button:has-text("CSV")').click(); + + await expect(page.locator('button:has-text("Downloaded!")')).toBeVisible({ timeout: CONTENT_TIMEOUT }); + }); + test('shows View Receipt button', async ({ page }) => { await waitForPage(page, VALID_TX); diff --git a/app/features/receipt/lib/__tests__/generate-receipt-pdf.test.ts b/app/features/receipt/lib/__tests__/generate-receipt-pdf.test.ts deleted file mode 100644 index 8c18cc133..000000000 --- a/app/features/receipt/lib/__tests__/generate-receipt-pdf.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { vi } from 'vitest'; - -import type { FormattedReceipt } from '../../types'; - -const mockTextField = vi.fn(() => ({ - defaultValue: '', - fieldName: '', - fontSize: 0, - height: 0, - value: '', - width: 0, - x: 0, - y: 0, -})); - -const mockSave = vi.fn(); -const mockAddField = vi.fn(); -const mockText = vi.fn(); -const mockAddImage = vi.fn(); -const mockSplitTextToSize = vi.fn((text: string, _maxWidth: number) => [text]); - -const mockDoc = { - AcroForm: { - TextField: mockTextField, - }, - addField: mockAddField, - addImage: mockAddImage, - getFontSize: vi.fn().mockReturnValue(8), - getStringUnitWidth: vi.fn().mockReturnValue(0), - internal: { scaleFactor: 1 }, - line: vi.fn(), - link: vi.fn(), - rect: vi.fn(), - roundedRect: vi.fn(), - save: mockSave, - setDrawColor: vi.fn(), - setFillColor: vi.fn(), - setFont: vi.fn(), - setFontSize: vi.fn(), - setLineWidth: vi.fn(), - setTextColor: vi.fn(), - splitTextToSize: mockSplitTextToSize, - text: mockText, -}; - -vi.mock('jspdf', () => ({ - jsPDF: vi.fn(() => mockDoc), -})); - -const mockToDataURL = vi.fn().mockResolvedValue('data:image/png;base64,qrcode'); - -vi.mock('qrcode', () => ({ - default: { toDataURL: (...args: unknown[]) => mockToDataURL(...args) }, - toDataURL: (...args: unknown[]) => mockToDataURL(...args), -})); - -const RECEIPT: FormattedReceipt = { - date: { timestamp: 1700000000, utc: '2023-11-14 22:13:20 UTC' }, - fee: { formatted: '0.000005', raw: 5000 }, - memo: 'Payment for services', - network: 'mainnet-beta', - receiver: { address: 'ReceiverAddr2222222222222222222222222222222', truncated: 'Recv...2222' }, - sender: { address: 'SenderAddr111111111111111111111111111111111', truncated: 'Send...1111' }, - total: { formatted: '1.0', raw: 1000000000, unit: 'SOL' }, -}; - -const SIGNATURE = '5UfDuX7hXbGjGHqPXRGaHdSecretSignature1234567890abcdef'; -const RECEIPT_URL = 'https://explorer.solana.com/receipt/5UfDuX7hXbGjGHqPXRGaHdSecretSignature1234567890abcdef'; - -describe('generateReceiptPdf', () => { - let generateReceiptPdf: typeof import('../generate-receipt-pdf').generateReceiptPdf; - let loadPdfDeps: typeof import('../generate-receipt-pdf').loadPdfDeps; - - beforeEach(async () => { - vi.clearAllMocks(); - ({ generateReceiptPdf, loadPdfDeps } = await import('../generate-receipt-pdf')); - }); - - it('should create jsPDF instance with A4 format', async () => { - const { jsPDF } = await import('jspdf'); - const deps = await loadPdfDeps(); - await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); - expect(jsPDF).toHaveBeenCalledWith({ format: 'a4', unit: 'mm' }); - }); - - it('should add pre-filled text for all payment detail fields', async () => { - const deps = await loadPdfDeps(); - await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); - - const textCalls = mockText.mock.calls.map(([text]) => (Array.isArray(text) ? text.join(' ') : text)); - const allText = textCalls.join(' '); - - expect(allText).toContain('Solana Payment Receipt'); - expect(allText).toContain('Solana (SOL)'); - expect(allText).toContain('2023-11-14 22:13:20 UTC'); - expect(allText).toContain('1.0 SOL'); - expect(allText).toContain('SenderAddr111111111111111111111111111111111'); - expect(allText).toContain('ReceiverAddr2222222222222222222222222222222'); - expect(allText).toContain(SIGNATURE); - }); - - it('should create AcroForm text fields for editable sections', async () => { - const deps = await loadPdfDeps(); - await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); - - const fieldNames = mockTextField.mock.results.map(r => r.value.fieldName).filter(Boolean); - - expect(fieldNames).toContain('supplier_name'); - expect(fieldNames).toContain('supplier_address'); - expect(fieldNames).toContain('items_description'); - }); - - it('should render Total as a pre-filled editable field', async () => { - const deps = await loadPdfDeps(); - await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); - - const fieldNames = mockTextField.mock.results.map(r => r.value.fieldName).filter(Boolean); - expect(fieldNames).toContain('total'); - - const textCalls = mockText.mock.calls.map(([text]) => (Array.isArray(text) ? text.join(' ') : text)); - const allText = textCalls.join(' '); - expect(allText).toContain('TOTAL'); - }); - - it('should call doc.save with full signature filename', async () => { - const deps = await loadPdfDeps(); - await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); - - expect(mockSave).toHaveBeenCalledWith(`solana-receipt-${SIGNATURE}.pdf`); - }); - - it('should handle missing memo gracefully', async () => { - const receiptWithoutMemo: FormattedReceipt = { ...RECEIPT, memo: undefined }; - const deps = await loadPdfDeps(); - - await generateReceiptPdf(deps, receiptWithoutMemo, SIGNATURE, RECEIPT_URL); - - const textCalls = mockText.mock.calls.map(([text]) => (Array.isArray(text) ? text.join(' ') : text)); - const allText = textCalls.join(' '); - - expect(allText).not.toContain('Transaction Memo'); - expect(mockSave).toHaveBeenCalled(); - }); - - it('should add editable fields via addField for each AcroForm field', async () => { - const deps = await loadPdfDeps(); - await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); - - expect(mockAddField).toHaveBeenCalled(); - expect(mockAddField.mock.calls.length).toBeGreaterThan(0); - }); - - it('should include memo in text when present', async () => { - const deps = await loadPdfDeps(); - await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); - - const textCalls = mockText.mock.calls.map(([text]) => (Array.isArray(text) ? text.join(' ') : text)); - const allText = textCalls.join(' '); - - expect(allText).toContain('TRANSACTION MEMO'); - expect(allText).toContain('Payment for services'); - }); - - it('should embed QR code image in the PDF', async () => { - const deps = await loadPdfDeps(); - await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); - - expect(mockToDataURL).toHaveBeenCalledWith(RECEIPT_URL, { margin: 0, width: 200 }); - - const addImageCalls = mockAddImage.mock.calls; - const qrCall = addImageCalls.find(([dataUrl]) => dataUrl === 'data:image/png;base64,qrcode'); - expect(qrCall).toBeDefined(); - - const textCalls = mockText.mock.calls.map(([text]) => (Array.isArray(text) ? text.join(' ') : text)); - const allText = textCalls.join(' '); - expect(allText).toContain('Verify on Solana Explorer'); - }); - - it('should handle QR code generation failure gracefully', async () => { - mockToDataURL.mockRejectedValueOnce(new Error('QR generation failed')); - const deps = await loadPdfDeps(); - - await generateReceiptPdf(deps, RECEIPT, SIGNATURE, RECEIPT_URL); - - expect(mockSave).toHaveBeenCalledWith(`solana-receipt-${SIGNATURE}.pdf`); - }); -}); diff --git a/app/features/receipt/lib/__tests__/normalize-search-params.test.ts b/app/features/receipt/lib/__tests__/normalize-search-params.test.ts deleted file mode 100644 index 373ac6dfe..000000000 --- a/app/features/receipt/lib/__tests__/normalize-search-params.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { normalizeSearchParams } from '../normalize-search-params'; - -describe('normalizeSearchParams', () => { - it('returns empty object for empty input', () => { - expect(normalizeSearchParams({})).toEqual({}); - }); - - it('leaves normal params unchanged', () => { - expect(normalizeSearchParams({ foo: 'bar', view: 'receipt' })).toEqual({ - foo: 'bar', - view: 'receipt', - }); - }); - - it('exposes HTML-entity-mangled key under real name (amp;cluster → cluster)', () => { - expect(normalizeSearchParams({ 'amp;cluster': 'devnet', view: 'receipt' })).toEqual({ - cluster: 'devnet', - view: 'receipt', - }); - }); - - it('prefers real key when both real and amp;-prefixed param exist', () => { - expect(normalizeSearchParams({ 'amp;cluster': 'devnet', cluster: 'testnet' })).toEqual({ - cluster: 'testnet', - }); - }); - - it('normalizes multiple amp;-prefixed params', () => { - expect( - normalizeSearchParams({ - 'amp;cluster': 'devnet', - 'amp;customUrl': 'https://example.com', - view: 'receipt', - }), - ).toEqual({ - cluster: 'devnet', - customUrl: 'https://example.com', - view: 'receipt', - }); - }); - - it('preserves array values', () => { - expect(normalizeSearchParams({ 'amp;cluster': 'devnet', ids: ['a', 'b'] })).toEqual({ - cluster: 'devnet', - ids: ['a', 'b'], - }); - }); - - it('normalizes many amp;-prefixed params (long query string)', () => { - expect( - normalizeSearchParams({ - 'amp;a': '1', - 'amp;b': '2', - 'amp;c': '3', - 'amp;cluster': 'devnet', - 'amp;customUrl': 'https://example.com', - 'amp;view': 'receipt', - }), - ).toEqual({ - a: '1', - b: '2', - c: '3', - cluster: 'devnet', - customUrl: 'https://example.com', - view: 'receipt', - }); - }); - - it('prefers real keys when multiple pairs of real and amp;-prefixed exist', () => { - expect( - normalizeSearchParams({ - 'amp;cluster': 'devnet', - 'amp;customUrl': 'https://wrong.com', - 'amp;view': 'preview', - cluster: 'testnet', - customUrl: 'https://correct.com', - view: 'receipt', - }), - ).toEqual({ - cluster: 'testnet', - customUrl: 'https://correct.com', - view: 'receipt', - }); - }); - - it('handles only amp;-prefixed params (no normal keys)', () => { - expect( - normalizeSearchParams({ - 'amp;bar': 'y', - 'amp;baz': 'z', - 'amp;foo': 'x', - }), - ).toEqual({ - bar: 'y', - baz: 'z', - foo: 'x', - }); - }); - - it('normalizes double-encoded amp;amp;foo to single amp;foo key (one level)', () => { - // If a client sent ?foo=1&amp;bar=2, we get key "amp;amp;bar" -> realKey "amp;bar" - expect(normalizeSearchParams({ 'amp;amp;bar': '2', foo: '1' })).toEqual({ - 'amp;bar': '2', - foo: '1', - }); - }); - - it('key exactly "amp;" becomes empty string key', () => { - expect(normalizeSearchParams({ 'amp;': 'orphan' })).toEqual({ - '': 'orphan', - }); - }); - - it('returns empty object for null/undefined-like input', () => { - expect(normalizeSearchParams(null as unknown as Record)).toEqual({}); - expect(normalizeSearchParams(undefined as unknown as Record)).toEqual({}); - }); -}); diff --git a/app/features/receipt/lib/__tests__/use-download-receipt.test.ts b/app/features/receipt/lib/__tests__/use-download-receipt.test.ts deleted file mode 100644 index 0f9274a42..000000000 --- a/app/features/receipt/lib/__tests__/use-download-receipt.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { vi } from 'vitest'; - -describe('useDownloadReceipt', () => { - let useDownloadReceipt: typeof import('../use-download-receipt').useDownloadReceipt; - - beforeEach(async () => { - vi.useFakeTimers(); - ({ useDownloadReceipt } = await import('../use-download-receipt')); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it('should start with idle state', () => { - const download = vi.fn().mockResolvedValue(undefined); - const { result } = renderHook(() => useDownloadReceipt(download)); - expect(result.current[0]).toBe('idle'); - }); - - it('should transition to downloading then downloaded on success', async () => { - let resolve: () => void = () => {}; - const download = vi.fn().mockReturnValue( - new Promise(r => { - resolve = r; - }) - ); - - const { result } = renderHook(() => useDownloadReceipt(download)); - - act(() => { - result.current[1](); - }); - - expect(result.current[0]).toBe('downloading'); - - await act(async () => { - resolve(); - }); - - expect(result.current[0]).toBe('downloaded'); - }); - - it('should transition to errored on failure', async () => { - const download = vi.fn().mockRejectedValue(new Error('network error')); - const { result } = renderHook(() => useDownloadReceipt(download)); - - await act(async () => { - result.current[1](); - }); - - expect(result.current[0]).toBe('errored'); - }); - - it('should reset to idle after resetMs', async () => { - const download = vi.fn().mockResolvedValue(undefined); - const { result } = renderHook(() => useDownloadReceipt(download, 500)); - - await act(async () => { - result.current[1](); - }); - - expect(result.current[0]).toBe('downloaded'); - - act(() => { - vi.advanceTimersByTime(500); - }); - - expect(result.current[0]).toBe('idle'); - }); - - it('should reset to idle after resetMs when errored', async () => { - const download = vi.fn().mockRejectedValue(new Error('fail')); - const { result } = renderHook(() => useDownloadReceipt(download, 500)); - - await act(async () => { - result.current[1](); - }); - - expect(result.current[0]).toBe('errored'); - - act(() => { - vi.advanceTimersByTime(500); - }); - - expect(result.current[0]).toBe('idle'); - }); - - it('should ignore clicks while downloading', async () => { - let resolve: () => void = () => {}; - const download = vi.fn().mockReturnValue( - new Promise(r => { - resolve = r; - }) - ); - - const { result } = renderHook(() => useDownloadReceipt(download)); - - act(() => { - result.current[1](); - }); - - expect(download).toHaveBeenCalledTimes(1); - - act(() => { - result.current[1](); - }); - - expect(download).toHaveBeenCalledTimes(1); - - await act(async () => { - resolve(); - }); - }); - - it('should allow re-download after reset', async () => { - const download = vi.fn().mockResolvedValue(undefined); - const { result } = renderHook(() => useDownloadReceipt(download, 500)); - - await act(async () => { - result.current[1](); - }); - - expect(result.current[0]).toBe('downloaded'); - - act(() => { - vi.advanceTimersByTime(500); - }); - - expect(result.current[0]).toBe('idle'); - - await act(async () => { - result.current[1](); - }); - - expect(result.current[0]).toBe('downloaded'); - expect(download).toHaveBeenCalledTimes(2); - }); - - it('should clean up timeout on unmount', async () => { - const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); - const download = vi.fn().mockResolvedValue(undefined); - const { result, unmount } = renderHook(() => useDownloadReceipt(download, 1000)); - - await act(async () => { - result.current[1](); - }); - - expect(result.current[0]).toBe('downloaded'); - - unmount(); - - expect(clearTimeoutSpy).toHaveBeenCalled(); - - act(() => { - vi.advanceTimersByTime(1000); - }); - }); -}); diff --git a/app/features/receipt/lib/use-download-receipt.ts b/app/features/receipt/lib/use-download-receipt.ts deleted file mode 100644 index 1a499758c..000000000 --- a/app/features/receipt/lib/use-download-receipt.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; - -import { Logger } from '@/app/shared/lib/logger'; - -import type { DownloadReceiptFn } from '../types'; - -export type DownloadState = 'idle' | 'downloading' | 'downloaded' | 'errored'; - -export function useDownloadReceipt(download: DownloadReceiptFn, resetMs = 2000): readonly [DownloadState, () => void] { - const [state, setState] = useState('idle'); - const stateRef = useRef('idle'); - const timeoutRef = useRef>(); - const mountedRef = useRef(true); - - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - clearTimeout(timeoutRef.current); - }; - }, []); - - const scheduleReset = useCallback(() => { - timeoutRef.current = setTimeout(() => { - stateRef.current = 'idle'; - setState('idle'); - }, resetMs); - }, [resetMs]); - - const trigger = useCallback(() => { - if (stateRef.current === 'downloading') return; - - clearTimeout(timeoutRef.current); - stateRef.current = 'downloading'; - setState('downloading'); - - download().then( - () => { - if (!mountedRef.current) return; - stateRef.current = 'downloaded'; - setState('downloaded'); - scheduleReset(); - }, - (error: unknown) => { - if (!mountedRef.current) return; - Logger.error(error); - stateRef.current = 'errored'; - setState('errored'); - scheduleReset(); - } - ); - }, [download, scheduleReset]); - - return [state, trigger] as const; -} diff --git a/app/utils/__tests__/formatUsdValue.test.ts b/app/utils/__tests__/formatUsdValue.test.ts deleted file mode 100644 index 42a17eec6..000000000 --- a/app/utils/__tests__/formatUsdValue.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { formatUsdValue } from '@utils/index'; - -describe('formatUsdValue', () => { - it('formats a normal value', () => { - expect(formatUsdValue(1, 200)).toBe('$200.00'); - }); - - it('formats fractional result with 2 decimal places', () => { - expect(formatUsdValue(0.5, 3)).toBe('$1.50'); - }); - - it('adds thousands separator', () => { - expect(formatUsdValue(10, 1000)).toBe('$10,000.00'); - }); - - it('returns $0.00 for zero amount', () => { - expect(formatUsdValue(0, 200)).toBe('$0.00'); - }); - - it('returns $0.00 for zero price', () => { - expect(formatUsdValue(1, 0)).toBe('$0.00'); - }); - - it('returns $0.00 for NaN amount', () => { - expect(formatUsdValue(NaN, 200)).toBe('$0.00'); - }); - - it('returns $0.00 for NaN price', () => { - expect(formatUsdValue(1, NaN)).toBe('$0.00'); - }); - - it('returns $0.00 for negative amount', () => { - expect(formatUsdValue(-1, 200)).toBe('$0.00'); - }); - - it('returns $0.00 for negative price', () => { - expect(formatUsdValue(1, -200)).toBe('$0.00'); - }); -}); From 4d8aa4d0e375fd9512b22b33c8042078ea8f60b2 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Mon, 23 Mar 2026 23:24:26 +0100 Subject: [PATCH 14/33] test rename --- ...{generate-receipt-csv.test.ts => generate-receipt-csv.spec.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/features/receipt/lib/__tests__/{generate-receipt-csv.test.ts => generate-receipt-csv.spec.ts} (100%) diff --git a/app/features/receipt/lib/__tests__/generate-receipt-csv.test.ts b/app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts similarity index 100% rename from app/features/receipt/lib/__tests__/generate-receipt-csv.test.ts rename to app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts From da2b07444f11e5af5c1eef2eef2b5bc8c0d81831 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Mon, 23 Mar 2026 23:26:39 +0100 Subject: [PATCH 15/33] removed duplicated test --- .../lib/__tests__/use-primary-domain.test.ts | 96 ------------------- 1 file changed, 96 deletions(-) delete mode 100644 app/features/receipt/lib/__tests__/use-primary-domain.test.ts diff --git a/app/features/receipt/lib/__tests__/use-primary-domain.test.ts b/app/features/receipt/lib/__tests__/use-primary-domain.test.ts deleted file mode 100644 index 5836f7f25..000000000 --- a/app/features/receipt/lib/__tests__/use-primary-domain.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { useUserANSDomains, useUserSnsDomains } from '@entities/domain'; -import { renderHook } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { usePrimaryDomain } from '../use-primary-domain'; - -vi.mock('@entities/domain', () => ({ useUserANSDomains: vi.fn(), useUserSnsDomains: vi.fn() })); - -const VALID_ADDRESS = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; - -const swrStyle = (data: unknown) => ({ - data, - error: undefined, - isLoading: false, - isValidating: false, - mutate: () => {}, -}); - -describe('usePrimaryDomain', () => { - beforeEach(() => { - vi.mocked(useUserSnsDomains).mockReturnValue(swrStyle(undefined) as ReturnType); - vi.mocked(useUserANSDomains).mockReturnValue(swrStyle(undefined) as ReturnType); - }); - - it('returns undefined when both SOL and ANS domains are null', () => { - const { result } = renderHook(() => usePrimaryDomain(VALID_ADDRESS)); - expect(result.current).toBeUndefined(); - }); - - it('returns undefined when both SOL and ANS domains are empty arrays', () => { - vi.mocked(useUserSnsDomains).mockReturnValue(swrStyle([]) as ReturnType); - vi.mocked(useUserANSDomains).mockReturnValue(swrStyle([]) as ReturnType); - - const { result } = renderHook(() => usePrimaryDomain(VALID_ADDRESS)); - expect(result.current).toBeUndefined(); - }); - - it('returns first SOL domain when only SOL domains exist (sorted by name)', () => { - vi.mocked(useUserSnsDomains).mockReturnValue( - swrStyle([ - { address: 'addr1', name: 'alex.sol' }, - { address: 'addr2', name: 'bob.sol' }, - ]) as ReturnType, - ); - - const { result } = renderHook(() => usePrimaryDomain(VALID_ADDRESS)); - expect(result.current).toBe('alex.sol'); - }); - - it('returns first ANS domain when SNS domains are empty (sorted by name)', () => { - vi.mocked(useUserSnsDomains).mockReturnValue(swrStyle([]) as ReturnType); - vi.mocked(useUserANSDomains).mockReturnValue( - swrStyle([ - { address: 'addr1', name: 'alice.abc' }, - { address: 'addr2', name: 'charlie.abc' }, - ]) as ReturnType, - ); - - const { result } = renderHook(() => usePrimaryDomain(VALID_ADDRESS)); - expect(result.current).toBe('alice.abc'); - }); - - it('prefers SOL domain over ANS when both exist', () => { - vi.mocked(useUserSnsDomains).mockReturnValue( - swrStyle([{ address: 'addr1', name: 'user.sol' }]) as ReturnType, - ); - vi.mocked(useUserANSDomains).mockReturnValue( - swrStyle([{ address: 'addr2', name: 'user.abc' }]) as ReturnType, - ); - - const { result } = renderHook(() => usePrimaryDomain(VALID_ADDRESS)); - expect(result.current).toBe('user.sol'); - }); - - it('returns ANS domain when SOL is empty and ANS has domains', () => { - vi.mocked(useUserSnsDomains).mockReturnValue(swrStyle([]) as ReturnType); - vi.mocked(useUserANSDomains).mockReturnValue( - swrStyle([{ address: 'addr1', name: 'fallback.abc' }]) as ReturnType, - ); - - const { result } = renderHook(() => usePrimaryDomain(VALID_ADDRESS)); - expect(result.current).toBe('fallback.abc'); - }); - - it('passes address to SNS hook and disables ANS when SNS is still loading', () => { - renderHook(() => usePrimaryDomain(VALID_ADDRESS)); - expect(useUserSnsDomains).toHaveBeenCalledWith(VALID_ADDRESS); - expect(useUserANSDomains).toHaveBeenCalledWith(''); - }); - - it('passes address to ANS hook only when SNS returns empty', () => { - vi.mocked(useUserSnsDomains).mockReturnValue(swrStyle([]) as ReturnType); - renderHook(() => usePrimaryDomain(VALID_ADDRESS)); - expect(useUserANSDomains).toHaveBeenCalledWith(VALID_ADDRESS); - }); -}); From ee1acc3ea85c312c6580d1699da3291c929dd51f Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Tue, 24 Mar 2026 10:17:56 +0100 Subject: [PATCH 16/33] format prettier --- app/features/receipt/__e2e__/receipt.e2e.ts | 2 +- app/features/receipt/lib/generate-receipt-csv.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/features/receipt/__e2e__/receipt.e2e.ts b/app/features/receipt/__e2e__/receipt.e2e.ts index 82daba78f..525fafea4 100644 --- a/app/features/receipt/__e2e__/receipt.e2e.ts +++ b/app/features/receipt/__e2e__/receipt.e2e.ts @@ -81,7 +81,7 @@ test.describe('when feature enabled', () => { text.includes('Error') ); }, - { timeout: CONTENT_TIMEOUT } + { timeout: CONTENT_TIMEOUT }, ); const text = await page.textContent('body'); diff --git a/app/features/receipt/lib/generate-receipt-csv.ts b/app/features/receipt/lib/generate-receipt-csv.ts index af6281103..4c47bad85 100644 --- a/app/features/receipt/lib/generate-receipt-csv.ts +++ b/app/features/receipt/lib/generate-receipt-csv.ts @@ -37,7 +37,7 @@ export function buildReceiptCsvRow(receipt: FormattedReceipt, signature: string, export async function generateReceiptCsv( receipt: FormattedReceipt, signature: string, - usdValue?: string + usdValue?: string, ): Promise { const row = buildReceiptCsvRow(receipt, signature, usdValue); const csv = await writeToString([row], { headers: [...CSV_HEADERS] }); From 4747ba008cc26455cfbdf01aa0bae96a1290f759 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Tue, 24 Mar 2026 13:04:57 +0100 Subject: [PATCH 17/33] added sanitizeCsvField --- app/features/receipt/lib/generate-receipt-csv.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/features/receipt/lib/generate-receipt-csv.ts b/app/features/receipt/lib/generate-receipt-csv.ts index 4c47bad85..e3b00b03e 100644 --- a/app/features/receipt/lib/generate-receipt-csv.ts +++ b/app/features/receipt/lib/generate-receipt-csv.ts @@ -16,6 +16,10 @@ const CSV_HEADERS = [ 'Memo', ] as const; +function sanitizeCsvField(value: string): string { + return /^[=+\-@\t\r]/.test(value) ? `'${value}` : value; +} + export function buildReceiptCsvRow(receipt: FormattedReceipt, signature: string, usdValue?: string): string[] { const mint = 'mint' in receipt ? receipt.mint : undefined; @@ -30,7 +34,7 @@ export function buildReceiptCsvRow(receipt: FormattedReceipt, signature: string, mint ?? '', usdValue ?? '', receipt.fee.formatted, - receipt.memo ?? '', + sanitizeCsvField(receipt.memo ?? ''), ]; } From 923680d87b22b68f63fd8b1cf727dc4f94acf97e Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Tue, 24 Mar 2026 15:34:18 +0100 Subject: [PATCH 18/33] add eslint disable comment --- app/features/receipt/lib/generate-receipt-csv.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/features/receipt/lib/generate-receipt-csv.ts b/app/features/receipt/lib/generate-receipt-csv.ts index e3b00b03e..914187244 100644 --- a/app/features/receipt/lib/generate-receipt-csv.ts +++ b/app/features/receipt/lib/generate-receipt-csv.ts @@ -17,6 +17,7 @@ const CSV_HEADERS = [ ] as const; function sanitizeCsvField(value: string): string { + // eslint-disable-next-line no-restricted-syntax -- regex is the clearest way to express CSV formula injection chars return /^[=+\-@\t\r]/.test(value) ? `'${value}` : value; } From b9a86beb95ed42a897119ed4652dee7f3dfef02a Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Wed, 25 Mar 2026 11:17:44 +0100 Subject: [PATCH 19/33] resolve comments --- app/features/receipt/__e2e__/receipt.e2e.ts | 57 ++++++------------- .../receipt/lib/generate-receipt-csv.ts | 2 +- app/styles.css | 14 ----- package.json | 2 +- 4 files changed, 19 insertions(+), 56 deletions(-) diff --git a/app/features/receipt/__e2e__/receipt.e2e.ts b/app/features/receipt/__e2e__/receipt.e2e.ts index 525fafea4..65078297e 100644 --- a/app/features/receipt/__e2e__/receipt.e2e.ts +++ b/app/features/receipt/__e2e__/receipt.e2e.ts @@ -47,16 +47,7 @@ test.describe('when feature enabled', () => { }); test('renders receipt for valid transaction', async ({ page }) => { - await waitForPage(page, VALID_TX, 'receipt'); - - await page - .locator('h3:has-text("Solana Receipt")') - .or(page.locator('text=Not Found')) - .or(page.locator('text=Receipts can only be generated')) - .first() - .waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT }); - - const hasReceipt = await hasElement(page, 'h3:has-text("Solana Receipt")'); + const hasReceipt = await navigateToReceipt(page); const hasError = await hasElement(page, 'text=Not Found'); const hasNoReceipt = await hasElement(page, 'text=Receipts can only be generated'); @@ -94,16 +85,7 @@ test.describe('when feature enabled', () => { }); test('shows CSV and PDF options in download menu', async ({ page }) => { - await waitForPage(page, VALID_TX, 'receipt'); - - await page - .locator('h3:has-text("Solana Receipt")') - .or(page.locator('text=Not Found')) - .or(page.locator('text=Receipts can only be generated')) - .first() - .waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT }); - - const hasReceipt = await hasElement(page, 'h3:has-text("Solana Receipt")'); + const hasReceipt = await navigateToReceipt(page); if (!hasReceipt) return; await page.locator('button:has-text("Download")').click(); @@ -112,16 +94,7 @@ test.describe('when feature enabled', () => { }); test('triggers CSV download with correct filename', async ({ page }) => { - await waitForPage(page, VALID_TX, 'receipt'); - - await page - .locator('h3:has-text("Solana Receipt")') - .or(page.locator('text=Not Found')) - .or(page.locator('text=Receipts can only be generated')) - .first() - .waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT }); - - const hasReceipt = await hasElement(page, 'h3:has-text("Solana Receipt")'); + const hasReceipt = await navigateToReceipt(page); if (!hasReceipt) return; await page.locator('button:has-text("Download")').click(); @@ -136,16 +109,7 @@ test.describe('when feature enabled', () => { }); test('shows Downloaded! state after CSV download completes', async ({ page }) => { - await waitForPage(page, VALID_TX, 'receipt'); - - await page - .locator('h3:has-text("Solana Receipt")') - .or(page.locator('text=Not Found')) - .or(page.locator('text=Receipts can only be generated')) - .first() - .waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT }); - - const hasReceipt = await hasElement(page, 'h3:has-text("Solana Receipt")'); + const hasReceipt = await navigateToReceipt(page); if (!hasReceipt) return; await page.locator('button:has-text("Download")').click(); @@ -220,6 +184,19 @@ async function hasElement(page: Page, selector: string, timeout = 10000): Promis } } +async function navigateToReceipt(page: Page): Promise { + await waitForPage(page, VALID_TX, 'receipt'); + + await page + .locator('h3:has-text("Solana Receipt")') + .or(page.locator('text=Not Found')) + .or(page.locator('text=Receipts can only be generated')) + .first() + .waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT }); + + return hasElement(page, 'h3:has-text("Solana Receipt")'); +} + async function waitForPage(page: Page, tx: string, view?: 'receipt') { const url = view ? `/tx/${tx}?view=${view}` : `/tx/${tx}`; diff --git a/app/features/receipt/lib/generate-receipt-csv.ts b/app/features/receipt/lib/generate-receipt-csv.ts index 914187244..4b44cde6a 100644 --- a/app/features/receipt/lib/generate-receipt-csv.ts +++ b/app/features/receipt/lib/generate-receipt-csv.ts @@ -22,7 +22,7 @@ function sanitizeCsvField(value: string): string { } export function buildReceiptCsvRow(receipt: FormattedReceipt, signature: string, usdValue?: string): string[] { - const mint = 'mint' in receipt ? receipt.mint : undefined; + const mint = receipt.kind === 'token' ? receipt.mint : undefined; return [ receipt.date.utc, diff --git a/app/styles.css b/app/styles.css index 8521291cd..e78102b48 100644 --- a/app/styles.css +++ b/app/styles.css @@ -141,20 +141,6 @@ } } -@media print { - .print\:e-hidden { - display: none !important; - } - - .print\:e-border-transparent { - border-color: transparent !important; - } - - .print\:e-bg-transparent { - background-color: transparent !important; - } -} - /* When a printable receipt is present, hide everything else during print */ @media print { body:has(.printable-receipt) { diff --git a/package.json b/package.json index cdffd29b3..0f7bf19c9 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "codama": "1.2.11", "cross-fetch": "3.2.0", "cross-spawn": "7.0.6", - "fast-csv": "^5.0.5", + "fast-csv": "5.0.5", "fuse.js": "7.1.0", "humanize-duration-ts": "2.1.1", "ipaddr.js": "2.2.0", From ef9bd1c0fc96247c29e25c5ae5b8f501cefd6a40 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Wed, 25 Mar 2026 12:55:05 +0100 Subject: [PATCH 20/33] lock file update --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3a223eb8..4ec004847 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,7 +232,7 @@ importers: specifier: 7.0.6 version: 7.0.6 fast-csv: - specifier: ^5.0.5 + specifier: 5.0.5 version: 5.0.5 fuse.js: specifier: 7.1.0 From ff1162e1c1ebc69038398bc2480025fa29c6d8e5 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Wed, 25 Mar 2026 13:28:50 +0100 Subject: [PATCH 21/33] added kind to tests --- app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts b/app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts index 6d15c8e0d..460c0a1fd 100644 --- a/app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts +++ b/app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts @@ -6,6 +6,7 @@ import { buildReceiptCsvRow, generateReceiptCsv } from '../generate-receipt-csv' const RECEIPT: FormattedReceipt = { date: { timestamp: 1700000000, utc: '2023-11-14 22:13:20 UTC' }, fee: { formatted: '0.000005', raw: 5000 }, + kind: 'sol', memo: 'Payment for services', network: 'mainnet-beta', receiver: { address: 'ReceiverAddr2222222222222222222222222222222', truncated: 'Recv...2222' }, @@ -41,6 +42,7 @@ describe('buildReceiptCsvRow', () => { it('should include mint address for token receipts', () => { const tokenReceipt: FormattedReceipt = { ...RECEIPT, + kind: 'token', mint: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', symbol: 'USDC', total: { formatted: '143.25', raw: 143.25, unit: 'USDC' }, From 55e42e89f7f66bf10058be7938222150ecd7317b Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Thu, 26 Mar 2026 11:55:20 +0100 Subject: [PATCH 22/33] added analytics by format --- app/features/receipt/ui/DownloadReceiptItem.tsx | 7 ++++--- app/features/receipt/ui/ReceiptView.tsx | 6 +++--- app/shared/lib/analytics/index.ts | 2 +- app/shared/lib/analytics/receipt.ts | 9 +++++++-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/app/features/receipt/ui/DownloadReceiptItem.tsx b/app/features/receipt/ui/DownloadReceiptItem.tsx index d05df26ee..45f3dec79 100644 --- a/app/features/receipt/ui/DownloadReceiptItem.tsx +++ b/app/features/receipt/ui/DownloadReceiptItem.tsx @@ -4,7 +4,7 @@ import type { TransactionSignature } from '@solana/web3.js'; import type { ReactNode } from 'react'; import { Check, Loader, XCircle } from 'react-feather'; -import { receiptAnalytics } from '@/app/shared/lib/analytics'; +import { EReceiptDownloadFormat, receiptAnalytics } from '@/app/shared/lib/analytics'; import { type DownloadState, useDownloadReceipt } from '../model/use-download-receipt'; import type { DownloadReceiptFn } from '../types'; @@ -39,14 +39,15 @@ interface DownloadReceiptItemProps { icon?: ReactNode; label: string; download: DownloadReceiptFn; + format: EReceiptDownloadFormat; signature: TransactionSignature; } -export function DownloadReceiptItem({ icon, label, download, signature }: DownloadReceiptItemProps) { +export function DownloadReceiptItem({ icon, label, download, format, signature }: DownloadReceiptItemProps) { const [state, trigger] = useDownloadReceipt(download); function handleTrigger() { - receiptAnalytics.trackDownload(signature); + receiptAnalytics.trackDownload(signature, format); trigger(); } diff --git a/app/features/receipt/ui/ReceiptView.tsx b/app/features/receipt/ui/ReceiptView.tsx index b6403da63..4c5df1dea 100644 --- a/app/features/receipt/ui/ReceiptView.tsx +++ b/app/features/receipt/ui/ReceiptView.tsx @@ -5,7 +5,7 @@ import { TransactionSignature } from '@solana/web3.js'; import Link from 'next/link'; import { Download, Share2 } from 'react-feather'; -import { receiptAnalytics } from '@/app/shared/lib/analytics'; +import { EReceiptDownloadFormat, receiptAnalytics } from '@/app/shared/lib/analytics'; import type { DownloadReceiptFn, FormattedExtendedReceipt } from '../types'; import { BaseReceipt, BlurredCircle } from './BaseReceipt'; @@ -50,8 +50,8 @@ export function ReceiptView({ data, downloadCsv, downloadPdf, signature, transac
} label="Download"> - - + +
diff --git a/app/shared/lib/analytics/index.ts b/app/shared/lib/analytics/index.ts index 6752a9f9f..ead5b2188 100644 --- a/app/shared/lib/analytics/index.ts +++ b/app/shared/lib/analytics/index.ts @@ -1,2 +1,2 @@ export { idlAnalytics } from './interactive-idl'; -export { receiptAnalytics } from './receipt'; +export { EReceiptDownloadFormat, receiptAnalytics } from './receipt'; diff --git a/app/shared/lib/analytics/receipt.ts b/app/shared/lib/analytics/receipt.ts index 97ec9ebc2..916a04db7 100644 --- a/app/shared/lib/analytics/receipt.ts +++ b/app/shared/lib/analytics/receipt.ts @@ -1,5 +1,10 @@ import { type GA4EventName, trackEvent } from './track-event'; +export enum EReceiptDownloadFormat { + Csv = 'csv', + Pdf = 'pdf', +} + export enum ReceiptEvent { ButtonClicked = 'rcpt_button_clicked', Download = 'rcpt_download', @@ -22,8 +27,8 @@ export const receiptAnalytics = { trackEvent(ReceiptEvent.ButtonClicked, { signature }); }, - trackDownload(signature: string): void { - trackEvent(ReceiptEvent.Download, { signature }); + trackDownload(signature: string, format: EReceiptDownloadFormat): void { + trackEvent(ReceiptEvent.Download, { format, signature }); }, trackNoReceipt(signature: string): void { From 33adcea90bb1d6823f6a258c8750f8d45f45a078 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Thu, 26 Mar 2026 11:56:56 +0100 Subject: [PATCH 23/33] removed unused style --- app/styles.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/styles.css b/app/styles.css index e78102b48..c9bb8449e 100644 --- a/app/styles.css +++ b/app/styles.css @@ -173,8 +173,4 @@ visibility: visible !important; } - @page { - margin: 0.5in; - size: A4; - } } From 2cde77740b0e2dad17beb7dd2110540d124c9173 Mon Sep 17 00:00:00 2001 From: Sergo Date: Thu, 26 Mar 2026 00:05:08 +0000 Subject: [PATCH 24/33] fix: add sanitization for incoming data --- .../lib/__tests__/generate-receipt-csv.spec.ts | 18 ++++++++++++++++++ .../receipt/lib/generate-receipt-csv.ts | 5 ++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts b/app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts index 460c0a1fd..5d44e3106 100644 --- a/app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts +++ b/app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts @@ -57,6 +57,24 @@ describe('buildReceiptCsvRow', () => { const row = buildReceiptCsvRow(receiptNoMemo, SIGNATURE); expect(row[10]).toBe(''); }); + + it('should sanitize memo with formula-injection prefix', () => { + const receipt: FormattedReceipt = { ...RECEIPT, memo: '=SUM(A1)' }; + const row = buildReceiptCsvRow(receipt, SIGNATURE); + expect(row[10]).toBe("'=SUM(A1)"); + }); + + it('should sanitize token symbol with formula-injection prefix', () => { + const receipt: FormattedReceipt = { + ...RECEIPT, + kind: 'token', + mint: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + symbol: '=EVIL', + total: { formatted: '100', raw: 100, unit: '=EVIL' }, + }; + const row = buildReceiptCsvRow(receipt, SIGNATURE); + expect(row[6]).toBe("'=EVIL"); + }); }); describe('generateReceiptCsv', () => { diff --git a/app/features/receipt/lib/generate-receipt-csv.ts b/app/features/receipt/lib/generate-receipt-csv.ts index 4b44cde6a..0b5aa82e4 100644 --- a/app/features/receipt/lib/generate-receipt-csv.ts +++ b/app/features/receipt/lib/generate-receipt-csv.ts @@ -16,6 +16,9 @@ const CSV_HEADERS = [ 'Memo', ] as const; +// Prevents CSV formula injection by prefixing dangerous leading characters with a single quote. +// fast-csv handles CSV formatting (quoting, delimiter escaping) but not application-level injection. +// Sanitize any field sourced from user-controlled or on-chain data (memo, token symbol). function sanitizeCsvField(value: string): string { // eslint-disable-next-line no-restricted-syntax -- regex is the clearest way to express CSV formula injection chars return /^[=+\-@\t\r]/.test(value) ? `'${value}` : value; @@ -31,7 +34,7 @@ export function buildReceiptCsvRow(receipt: FormattedReceipt, signature: string, receipt.sender.address, receipt.receiver.address, receipt.total.formatted, - receipt.total.unit, + sanitizeCsvField(receipt.total.unit), // token symbol comes from on-chain metadata mint ?? '', usdValue ?? '', receipt.fee.formatted, From 12e3e2d3cb2943a3cc622287a850686e8dddfd17 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Thu, 26 Mar 2026 13:41:30 +0100 Subject: [PATCH 25/33] fix style --- app/features/receipt/ui/ReceiptView.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/features/receipt/ui/ReceiptView.tsx b/app/features/receipt/ui/ReceiptView.tsx index 4c5df1dea..8caeb4d90 100644 --- a/app/features/receipt/ui/ReceiptView.tsx +++ b/app/features/receipt/ui/ReceiptView.tsx @@ -50,8 +50,18 @@ export function ReceiptView({ data, downloadCsv, downloadPdf, signature, transac
} label="Download"> - - + +
From 3205a7dce668d1839d7db2a0f23b5320d5e19170 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Thu, 26 Mar 2026 13:55:24 +0100 Subject: [PATCH 26/33] rollback native share + x share --- app/features/receipt/ui/ReceiptView.tsx | 45 +++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/app/features/receipt/ui/ReceiptView.tsx b/app/features/receipt/ui/ReceiptView.tsx index 8caeb4d90..a2d29dcb7 100644 --- a/app/features/receipt/ui/ReceiptView.tsx +++ b/app/features/receipt/ui/ReceiptView.tsx @@ -5,13 +5,16 @@ import { TransactionSignature } from '@solana/web3.js'; import Link from 'next/link'; import { Download, Share2 } from 'react-feather'; +import { useToast } from '@/app/components/shared/ui/sonner/use-toast'; import { EReceiptDownloadFormat, receiptAnalytics } from '@/app/shared/lib/analytics'; +import { useCanNativeShare } from '@/app/shared/lib/use-can-native-share'; import type { DownloadReceiptFn, FormattedExtendedReceipt } from '../types'; import { BaseReceipt, BlurredCircle } from './BaseReceipt'; import { CopyLinkShareItem } from './CopyLinkShareItem'; import { DownloadReceiptItem } from './DownloadReceiptItem'; import { PopoverButton } from './PopoverButton'; +import { ShareOnXShareItem } from './ShareOnXShareItem'; interface ReceiptViewProps { data: FormattedExtendedReceipt; @@ -22,10 +25,34 @@ interface ReceiptViewProps { } export function ReceiptView({ data, downloadCsv, downloadPdf, signature, transactionPath }: ReceiptViewProps) { + const canNativeShare = useCanNativeShare(); + const toast = useToast(); + function handleViewTxClick() { receiptAnalytics.trackViewTxClicked(signature); } + async function handleNativeShare() { + try { + const shareData = { + title: 'Solana Transaction Receipt', + url: globalThis.location.href, + }; + + if (!navigator.canShare?.(shareData)) { + toast.custom({ title: 'Sharing not supported for this content', type: 'error' }); + return; + } + await navigator.share(shareData); + receiptAnalytics.trackShareNative(signature); + } catch (e) { + if (e instanceof Error && e.name === 'AbortError') { + return; + } + toast.custom({ title: 'Failed to share', type: 'error' }); + } + } + return (
@@ -44,9 +71,21 @@ export function ReceiptView({ data, downloadCsv, downloadPdf, signature, transac
- } label="Share"> - receiptAnalytics.trackShareCopyLink(signature)} /> - + {canNativeShare ? ( + + ) : ( + + )}
} label="Download"> From 7ba408abdbd3e8582324d0a0074509a95b9f18df Mon Sep 17 00:00:00 2001 From: Sergo Date: Fri, 27 Mar 2026 17:08:42 +0000 Subject: [PATCH 27/33] fix: remove dead code --- app/styles.css | 36 +----------------------------------- 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/app/styles.css b/app/styles.css index c9bb8449e..f812818cc 100644 --- a/app/styles.css +++ b/app/styles.css @@ -139,38 +139,4 @@ .zigzag { mask: conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) 50%/21px 100%; } -} - -/* When a printable receipt is present, hide everything else during print */ -@media print { - body:has(.printable-receipt) { - margin: 0 !important; - padding: 0 !important; - background: white !important; - } - - body:has(.printable-receipt) > *:not(script) { - visibility: hidden !important; - height: 0 !important; - overflow: hidden !important; - padding: 0 !important; - margin: 0 !important; - } - - .printable-receipt { - visibility: visible !important; - position: fixed !important; - top: 0 !important; - left: 0 !important; - width: 100% !important; - height: auto !important; - overflow: visible !important; - background: white !important; - z-index: 999999 !important; - } - - .printable-receipt * { - visibility: visible !important; - } - -} +} \ No newline at end of file From 84f7975a43c5ca5ac5cd033915c748a06e674200 Mon Sep 17 00:00:00 2001 From: Sergo Date: Fri, 27 Mar 2026 17:09:46 +0000 Subject: [PATCH 28/33] fix: install only fast-csv subpackage --- .../receipt/lib/generate-receipt-csv.ts | 2 +- package.json | 2 +- pnpm-lock.yaml | 61 +++++++------------ 3 files changed, 24 insertions(+), 41 deletions(-) diff --git a/app/features/receipt/lib/generate-receipt-csv.ts b/app/features/receipt/lib/generate-receipt-csv.ts index 0b5aa82e4..a59f796fe 100644 --- a/app/features/receipt/lib/generate-receipt-csv.ts +++ b/app/features/receipt/lib/generate-receipt-csv.ts @@ -1,4 +1,4 @@ -import { writeToString } from 'fast-csv'; +import { writeToString } from '@fast-csv/format'; import type { FormattedReceipt } from '../types'; diff --git a/package.json b/package.json index 0f7bf19c9..bbb2def11 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "codama": "1.2.11", "cross-fetch": "3.2.0", "cross-spawn": "7.0.6", - "fast-csv": "5.0.5", + "@fast-csv/format": "5.0.5", "fuse.js": "7.1.0", "humanize-duration-ts": "2.1.1", "ipaddr.js": "2.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ec004847..e586dca92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@coral-xyz/anchor': specifier: 0.30.1 version: 0.30.1(bufferutil@4.0.7)(typescript@5.5.4)(utf-8-validate@5.0.10) + '@fast-csv/format': + specifier: 5.0.5 + version: 5.0.5 '@mantine/hooks': specifier: 7.17.3 version: 7.17.3(react@18.3.1) @@ -231,9 +234,6 @@ importers: cross-spawn: specifier: 7.0.6 version: 7.0.6 - fast-csv: - specifier: 5.0.5 - version: 5.0.5 fuse.js: specifier: 7.1.0 version: 7.1.0 @@ -1595,9 +1595,6 @@ packages: '@fast-csv/format@5.0.5': resolution: {integrity: sha512-0P9SJXXnqKdmuWlLaTelqbrfdgN37Mvrb369J6eNmqL41IEIZQmV4sNM4GgAK2Dz3aH04J0HKGDMJFkYObThTw==} - '@fast-csv/parse@5.0.5': - resolution: {integrity: sha512-M0IbaXZDbxfOnpVE5Kps/a6FGlILLhtLsvWd9qNH3d2TxNnpbNkFf3KD26OmJX6MHq7PdQAl5htStDwnuwHx6w==} - '@floating-ui/core@1.6.9': resolution: {integrity: sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==} @@ -1881,24 +1878,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@14.2.33': resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@14.2.33': resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@14.2.33': resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@14.2.33': resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} @@ -3360,56 +3361,67 @@ packages: resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.50.1': resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.50.1': resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.50.1': resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.50.1': resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.50.1': resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.50.1': resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.50.1': resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.50.1': resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.50.1': resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.50.1': resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.50.1': resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==} @@ -6777,10 +6789,6 @@ packages: resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} engines: {node: '> 0.1.90'} - fast-csv@5.0.5: - resolution: {integrity: sha512-9//QpogDIPln5Dc8e3Q3vbSSLXlTeU7z1JqsUOXZYOln8EIn/OOO8+NS2c3ukR6oYngDd3+P1HXSkby3kNV9KA==} - engines: {node: '>=10.0.0'} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -7906,24 +7914,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.29.2: resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.29.2: resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.29.2: resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.29.2: resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==} @@ -7972,9 +7984,6 @@ packages: lodash.flow@3.5.0: resolution: {integrity: sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw==} - lodash.groupby@4.6.0: - resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} - lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} @@ -7984,18 +7993,12 @@ packages: lodash.isnil@4.0.0: resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} - lodash.isundefined@3.0.1: - resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} lodash.throttle@4.1.1: resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} - lodash.uniq@4.5.0: - resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} - lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -12292,15 +12295,6 @@ snapshots: lodash.isfunction: 3.0.9 lodash.isnil: 4.0.0 - '@fast-csv/parse@5.0.5': - dependencies: - lodash.escaperegexp: 4.1.2 - lodash.groupby: 4.6.0 - lodash.isfunction: 3.0.9 - lodash.isnil: 4.0.0 - lodash.isundefined: 3.0.1 - lodash.uniq: 4.5.0 - '@floating-ui/core@1.6.9': dependencies: '@floating-ui/utils': 0.2.9 @@ -19160,11 +19154,6 @@ snapshots: eyes@0.1.8: {} - fast-csv@5.0.5: - dependencies: - '@fast-csv/format': 5.0.5 - '@fast-csv/parse': 5.0.5 - fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -20519,22 +20508,16 @@ snapshots: lodash.flow@3.5.0: {} - lodash.groupby@4.6.0: {} - lodash.isboolean@3.0.3: {} lodash.isfunction@3.0.9: {} lodash.isnil@4.0.0: {} - lodash.isundefined@3.0.1: {} - lodash.merge@4.6.2: {} lodash.throttle@4.1.1: {} - lodash.uniq@4.5.0: {} - lodash@4.17.21: {} log-symbols@6.0.0: From 23352ff1245448d4473f614ff9a91737a8345e38 Mon Sep 17 00:00:00 2001 From: Sergo Date: Fri, 27 Mar 2026 17:29:33 +0000 Subject: [PATCH 29/33] fix: extract entity-level logic from the feature --- .../token-receipt/__tests__/lib.spec.ts | 69 +++++++++++++++++++ app/entities/token-receipt/index.ts | 2 + app/entities/token-receipt/lib.ts | 17 +++++ app/entities/token-receipt/types.ts | 36 ++++++++++ .../__tests__/generate-receipt-csv.spec.ts | 5 ++ .../receipt/lib/generate-receipt-csv.ts | 6 +- .../receipt/lib/generate-receipt-pdf.ts | 5 +- app/features/receipt/receipt-page.tsx | 13 ++-- app/features/receipt/types.ts | 37 +--------- 9 files changed, 145 insertions(+), 45 deletions(-) create mode 100644 app/entities/token-receipt/__tests__/lib.spec.ts create mode 100644 app/entities/token-receipt/index.ts create mode 100644 app/entities/token-receipt/lib.ts create mode 100644 app/entities/token-receipt/types.ts diff --git a/app/entities/token-receipt/__tests__/lib.spec.ts b/app/entities/token-receipt/__tests__/lib.spec.ts new file mode 100644 index 000000000..0258d294f --- /dev/null +++ b/app/entities/token-receipt/__tests__/lib.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import type { FormattedReceipt } from '../types'; + +import { getReceiptAmount, getReceiptMint, getReceiptSymbol } from '../lib'; + +const SOL_RECEIPT: FormattedReceipt = { + date: { timestamp: 1700000000, utc: '2023-11-14 22:13:20 UTC' }, + fee: { formatted: '0.000005', raw: 5000 }, + kind: 'sol', + memo: undefined, + network: 'mainnet-beta', + receiver: { address: 'Recv2222', truncated: 'Recv...22' }, + sender: { address: 'Send1111', truncated: 'Send...11' }, + total: { formatted: '1.5', raw: 1_500_000_000, unit: 'SOL' }, +}; + +const TOKEN_RECEIPT: FormattedReceipt = { + date: { timestamp: 1700000000, utc: '2023-11-14 22:13:20 UTC' }, + fee: { formatted: '0.000005', raw: 5000 }, + kind: 'token', + memo: undefined, + mint: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + network: 'mainnet-beta', + receiver: { address: 'Recv2222', truncated: 'Recv...22' }, + sender: { address: 'Send1111', truncated: 'Send...11' }, + symbol: 'USDC', + total: { formatted: '143.25', raw: 143.25, unit: 'USDC' }, +}; + +describe('getReceiptMint', () => { + it('should return undefined for SOL receipts', () => { + expect(getReceiptMint(SOL_RECEIPT)).toBeUndefined(); + }); + + it('should return mint address for token receipts', () => { + expect(getReceiptMint(TOKEN_RECEIPT)).toBe('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'); + }); + + it('should return undefined for token receipts without mint', () => { + const receipt: FormattedReceipt = { ...TOKEN_RECEIPT, mint: undefined }; + expect(getReceiptMint(receipt)).toBeUndefined(); + }); +}); + +describe('getReceiptSymbol', () => { + it('should return undefined for SOL receipts', () => { + expect(getReceiptSymbol(SOL_RECEIPT)).toBeUndefined(); + }); + + it('should return symbol for token receipts', () => { + expect(getReceiptSymbol(TOKEN_RECEIPT)).toBe('USDC'); + }); + + it('should return undefined for token receipts without symbol', () => { + const receipt: FormattedReceipt = { ...TOKEN_RECEIPT, symbol: undefined }; + expect(getReceiptSymbol(receipt)).toBeUndefined(); + }); +}); + +describe('getReceiptAmount', () => { + it('should convert lamports to SOL for SOL receipts', () => { + expect(getReceiptAmount(SOL_RECEIPT)).toBe(1.5); + }); + + it('should return raw amount for token receipts', () => { + expect(getReceiptAmount(TOKEN_RECEIPT)).toBe(143.25); + }); +}); diff --git a/app/entities/token-receipt/index.ts b/app/entities/token-receipt/index.ts new file mode 100644 index 000000000..a888508ab --- /dev/null +++ b/app/entities/token-receipt/index.ts @@ -0,0 +1,2 @@ +export { getReceiptAmount, getReceiptMint, getReceiptSymbol } from './lib'; +export type { FormattedBaseReceipt, FormattedReceipt, FormattedReceiptToken } from './types'; diff --git a/app/entities/token-receipt/lib.ts b/app/entities/token-receipt/lib.ts new file mode 100644 index 000000000..e3eb56d02 --- /dev/null +++ b/app/entities/token-receipt/lib.ts @@ -0,0 +1,17 @@ +import { lamportsToSol } from '@utils/index'; + +import type { FormattedReceipt } from './types'; + +export function getReceiptMint(receipt: FormattedReceipt): string | undefined { + return 'mint' in receipt ? receipt.mint : undefined; +} + +export function getReceiptSymbol(receipt: FormattedReceipt): string | undefined { + return receipt.kind === 'token' ? receipt.symbol : undefined; +} + +// SOL receipts store total.raw in lamports; token receipts store it as a UI amount. +// Returns the amount in whole units suitable for price math. +export function getReceiptAmount(receipt: FormattedReceipt): number { + return receipt.kind === 'sol' ? lamportsToSol(receipt.total.raw) : receipt.total.raw; +} diff --git a/app/entities/token-receipt/types.ts b/app/entities/token-receipt/types.ts new file mode 100644 index 000000000..c68a0b83a --- /dev/null +++ b/app/entities/token-receipt/types.ts @@ -0,0 +1,36 @@ +export type FormattedBaseReceipt = { + date: { + timestamp: number; + utc: string; + }; + fee: { + raw: number; + formatted: string; + }; + total: { + raw: number; + formatted: string; + unit: string; + }; + network: string; + sender: { + address: string; + truncated: string; + domain?: string; + }; + receiver: { + address: string; + truncated: string; + domain?: string; + }; + memo?: string | undefined; + logoURI?: string | undefined; +}; + +export type FormattedReceiptToken = FormattedBaseReceipt & { + kind: 'token'; + mint?: string | undefined; + symbol?: string | undefined; +}; + +export type FormattedReceipt = (FormattedBaseReceipt & { kind: 'sol' }) | FormattedReceiptToken; diff --git a/app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts b/app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts index 5d44e3106..3154c3917 100644 --- a/app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts +++ b/app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts @@ -39,6 +39,11 @@ describe('buildReceiptCsvRow', () => { expect(row[8]).toBe('$150.00'); }); + it('should leave mint field empty for SOL receipts', () => { + const row = buildReceiptCsvRow(RECEIPT, SIGNATURE); + expect(row[7]).toBe(''); + }); + it('should include mint address for token receipts', () => { const tokenReceipt: FormattedReceipt = { ...RECEIPT, diff --git a/app/features/receipt/lib/generate-receipt-csv.ts b/app/features/receipt/lib/generate-receipt-csv.ts index a59f796fe..d5ac33257 100644 --- a/app/features/receipt/lib/generate-receipt-csv.ts +++ b/app/features/receipt/lib/generate-receipt-csv.ts @@ -1,5 +1,7 @@ import { writeToString } from '@fast-csv/format'; +import { getReceiptMint } from '@/app/entities/token-receipt'; + import type { FormattedReceipt } from '../types'; const CSV_HEADERS = [ @@ -25,7 +27,7 @@ function sanitizeCsvField(value: string): string { } export function buildReceiptCsvRow(receipt: FormattedReceipt, signature: string, usdValue?: string): string[] { - const mint = receipt.kind === 'token' ? receipt.mint : undefined; + const mint = getReceiptMint(receipt); return [ receipt.date.utc, @@ -38,7 +40,7 @@ export function buildReceiptCsvRow(receipt: FormattedReceipt, signature: string, mint ?? '', usdValue ?? '', receipt.fee.formatted, - sanitizeCsvField(receipt.memo ?? ''), + receipt.memo ? sanitizeCsvField(receipt.memo) : '', ]; } diff --git a/app/features/receipt/lib/generate-receipt-pdf.ts b/app/features/receipt/lib/generate-receipt-pdf.ts index de5893a9f..d8fe0bc57 100644 --- a/app/features/receipt/lib/generate-receipt-pdf.ts +++ b/app/features/receipt/lib/generate-receipt-pdf.ts @@ -1,6 +1,8 @@ import type { AcroFormTextField, jsPDF } from 'jspdf'; import type { toDataURL as ToDataURL } from 'qrcode'; +import { getReceiptSymbol } from '@/app/entities/token-receipt'; + import type { FormattedReceipt } from '../types'; import { applyLineStyle, @@ -193,7 +195,8 @@ export async function generateReceiptPdf( y = drawSectionTitle(doc, 'Payment Details', y); y += 2; - const paymentMethod = receipt.kind === 'token' && receipt.symbol ? `Solana (${receipt.symbol})` : 'Solana (SOL)'; + const symbol = getReceiptSymbol(receipt); + const paymentMethod = symbol ? `Solana (${symbol})` : 'Solana (SOL)'; const col2X = PAGE.marginX + PAGE.contentWidth / 2; // Row 1: Payment Method (left) + Payment Date (right) — stacked 2-column diff --git a/app/features/receipt/receipt-page.tsx b/app/features/receipt/receipt-page.tsx index 29a7dbe15..60ffa4465 100644 --- a/app/features/receipt/receipt-page.tsx +++ b/app/features/receipt/receipt-page.tsx @@ -11,12 +11,13 @@ import { useFetchTransactionDetails } from '@providers/transactions/parsed'; import { NATIVE_MINT } from '@solana/spl-token'; import { TransactionSignature } from '@solana/web3.js'; import { ClusterStatus } from '@utils/cluster'; -import { formatUsdValue, lamportsToSol } from '@utils/index'; +import { formatUsdValue } from '@utils/index'; import { useClusterPath } from '@utils/url'; import { useRouter } from 'next/navigation'; import React, { useCallback, useEffect } from 'react'; import useSWR from 'swr'; +import { getReceiptAmount, getReceiptMint } from '@/app/entities/token-receipt'; import { getProxiedUri } from '@/app/features/metadata'; import { receiptAnalytics } from '@/app/shared/lib/analytics'; import { Logger } from '@/app/shared/lib/logger'; @@ -141,14 +142,12 @@ function ReceiptContent({ receipt, signature, status, transactionPath }: Receipt const receiverDomain = usePrimaryDomain(receipt.receiver.address); const senderLink = useExplorerLink(`/address/${receipt.sender.address}`); const receiverLink = useExplorerLink(`/address/${receipt.receiver.address}`); - const tokenLink = useExplorerLink(receipt.kind === 'token' ? `/address/${receipt.mint}` : ''); + const receiptMint = getReceiptMint(receipt); + const tokenLink = useExplorerLink(receiptMint ? `/address/${receiptMint}` : ''); const logoURI = receipt.logoURI ? getProxiedUri(receipt.logoURI) : undefined; - const mint = receipt.kind === 'token' ? receipt.mint : NATIVE_MINT.toBase58(); - const priceResult = useTokenPrice(mint); - // SOL receipts store total.raw in lamports; token receipts store it as a UI amount. - // The price is always per 1 whole unit, so lamports must be converted to SOL first. - const amount = receipt.kind === 'sol' ? lamportsToSol(receipt.total.raw) : receipt.total.raw; + const priceResult = useTokenPrice(receiptMint ?? NATIVE_MINT.toBase58()); + const amount = getReceiptAmount(receipt); const usdValue = priceResult?.price != null ? formatUsdValue(amount, priceResult.price) : undefined; const downloadCsv = useCallback(async () => { diff --git a/app/features/receipt/types.ts b/app/features/receipt/types.ts index af82f16c9..59fe26493 100644 --- a/app/features/receipt/types.ts +++ b/app/features/receipt/types.ts @@ -1,39 +1,6 @@ -export type FormattedBaseReceipt = { - date: { - timestamp: number; - utc: string; - }; - fee: { - raw: number; - formatted: string; - }; - total: { - raw: number; - formatted: string; - unit: string; - }; - network: string; - sender: { - address: string; - truncated: string; - domain?: string; - }; - receiver: { - address: string; - truncated: string; - domain?: string; - }; - memo?: string | undefined; - logoURI?: string | undefined; -}; - -export type FormattedReceiptToken = FormattedBaseReceipt & { - kind: 'token'; - mint?: string | undefined; - symbol?: string | undefined; -}; +export type { FormattedBaseReceipt, FormattedReceipt, FormattedReceiptToken } from '@/app/entities/token-receipt'; -export type FormattedReceipt = (FormattedBaseReceipt & { kind: 'sol' }) | FormattedReceiptToken; +import type { FormattedReceipt } from '@/app/entities/token-receipt'; export type FormattedExtendedReceipt = FormattedReceipt & { confirmationStatus: string | undefined; From 83d8d8d0cbb5e02d3f28761efdeb751f9f0bfaff Mon Sep 17 00:00:00 2001 From: Sergo Date: Fri, 27 Mar 2026 17:31:11 +0000 Subject: [PATCH 30/33] fix: codestyle --- app/entities/token-receipt/__tests__/lib.spec.ts | 3 +-- .../receipt/lib/__tests__/generate-receipt-csv.spec.ts | 4 ++-- app/styles.css | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/entities/token-receipt/__tests__/lib.spec.ts b/app/entities/token-receipt/__tests__/lib.spec.ts index 0258d294f..75a6e6222 100644 --- a/app/entities/token-receipt/__tests__/lib.spec.ts +++ b/app/entities/token-receipt/__tests__/lib.spec.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { FormattedReceipt } from '../types'; - import { getReceiptAmount, getReceiptMint, getReceiptSymbol } from '../lib'; +import type { FormattedReceipt } from '../types'; const SOL_RECEIPT: FormattedReceipt = { date: { timestamp: 1700000000, utc: '2023-11-14 22:13:20 UTC' }, diff --git a/app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts b/app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts index 3154c3917..f1efea462 100644 --- a/app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts +++ b/app/features/receipt/lib/__tests__/generate-receipt-csv.spec.ts @@ -120,7 +120,7 @@ describe('generateReceiptCsv', () => { it('should pass a Blob with CSV mime type to createObjectURL', async () => { await generateReceiptCsv(RECEIPT, SIGNATURE); - // eslint-disable-next-line no-restricted-syntax -- accessing vitest mock internals for assertion + const blobArg = (URL.createObjectURL as ReturnType).mock.calls[0][0] as Blob; expect(blobArg).toBeInstanceOf(Blob); expect(blobArg.type).toBe('text/csv;charset=utf-8;'); @@ -128,7 +128,7 @@ describe('generateReceiptCsv', () => { it('should pass a non-empty Blob to createObjectURL', async () => { await generateReceiptCsv(RECEIPT, SIGNATURE); - // eslint-disable-next-line no-restricted-syntax -- accessing vitest mock internals for assertion + const blobArg = (URL.createObjectURL as ReturnType).mock.calls[0][0] as Blob; expect(blobArg.size).toBeGreaterThan(0); }); diff --git a/app/styles.css b/app/styles.css index f812818cc..ae0d3bc7e 100644 --- a/app/styles.css +++ b/app/styles.css @@ -139,4 +139,4 @@ .zigzag { mask: conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) 50%/21px 100%; } -} \ No newline at end of file +} From 02332e402be8a1b1ccd49259ceb1a4cb2b5a8db4 Mon Sep 17 00:00:00 2001 From: Tania Markina Date: Mon, 30 Mar 2026 14:36:36 +0200 Subject: [PATCH 31/33] add loading state for download btn --- app/features/receipt/receipt-page.tsx | 4 +++- app/features/receipt/ui/PopoverButton.tsx | 8 +++++--- app/features/receipt/ui/ReceiptView.tsx | 17 +++++++++++++++-- app/styles.css | 4 ++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/app/features/receipt/receipt-page.tsx b/app/features/receipt/receipt-page.tsx index 60ffa4465..e68f467a8 100644 --- a/app/features/receipt/receipt-page.tsx +++ b/app/features/receipt/receipt-page.tsx @@ -27,7 +27,7 @@ import { generateReceiptCsv } from './lib/generate-receipt-csv'; import { generateReceiptPdf, loadPdfDeps } from './lib/generate-receipt-pdf'; import { usePrimaryDomain } from './lib/use-primary-domain'; import { extractReceiptData } from './model/create-receipt'; -import { useTokenPrice } from './model/use-price'; +import { PriceStatus, useTokenPrice } from './model/use-price'; import type { FormattedReceipt } from './types'; import { NoReceipt } from './ui/BaseReceipt'; import { ReceiptView } from './ui/ReceiptView'; @@ -147,6 +147,7 @@ function ReceiptContent({ receipt, signature, status, transactionPath }: Receipt const logoURI = receipt.logoURI ? getProxiedUri(receipt.logoURI) : undefined; const priceResult = useTokenPrice(receiptMint ?? NATIVE_MINT.toBase58()); + const isPriceLoading = priceResult?.status === PriceStatus.Loading; const amount = getReceiptAmount(receipt); const usdValue = priceResult?.price != null ? formatUsdValue(amount, priceResult.price) : undefined; @@ -182,6 +183,7 @@ function ReceiptContent({ receipt, signature, status, transactionPath }: Receipt }} downloadCsv={downloadCsv} downloadPdf={downloadPdf} + isPriceLoading={isPriceLoading} signature={signature} transactionPath={transactionPath} /> diff --git a/app/features/receipt/ui/PopoverButton.tsx b/app/features/receipt/ui/PopoverButton.tsx index 9e92ee39e..59df0788b 100644 --- a/app/features/receipt/ui/PopoverButton.tsx +++ b/app/features/receipt/ui/PopoverButton.tsx @@ -8,14 +8,16 @@ interface PopoverButtonProps { label: string; children: ReactNode; className?: string; + disabled?: boolean; + loading?: boolean; } -export function PopoverButton({ icon, label, children, className }: PopoverButtonProps) { +export function PopoverButton({ icon, label, children, className, disabled, loading }: PopoverButtonProps) { return ( - diff --git a/app/features/receipt/ui/ReceiptView.tsx b/app/features/receipt/ui/ReceiptView.tsx index a2d29dcb7..b0177840c 100644 --- a/app/features/receipt/ui/ReceiptView.tsx +++ b/app/features/receipt/ui/ReceiptView.tsx @@ -20,11 +20,19 @@ interface ReceiptViewProps { data: FormattedExtendedReceipt; downloadCsv: DownloadReceiptFn; downloadPdf: DownloadReceiptFn; + isPriceLoading?: boolean; signature: TransactionSignature; transactionPath: string; } -export function ReceiptView({ data, downloadCsv, downloadPdf, signature, transactionPath }: ReceiptViewProps) { +export function ReceiptView({ + data, + downloadCsv, + downloadPdf, + isPriceLoading, + signature, + transactionPath, +}: ReceiptViewProps) { const canNativeShare = useCanNativeShare(); const toast = useToast(); @@ -88,7 +96,12 @@ export function ReceiptView({ data, downloadCsv, downloadPdf, signature, transac )}
- } label="Download"> + } + label="Download" + loading={isPriceLoading} + className="e-max-h-[25px]" + > Date: Mon, 30 Mar 2026 17:47:48 +0200 Subject: [PATCH 32/33] ui updates --- app/features/receipt/ui/DownloadReceiptItem.tsx | 16 ++++------------ app/features/receipt/ui/PopoverButton.tsx | 2 +- app/features/receipt/ui/PopoverMenuItem.tsx | 4 +++- app/features/receipt/ui/ReceiptView.tsx | 4 +++- 4 files changed, 11 insertions(+), 15 deletions(-) diff --git a/app/features/receipt/ui/DownloadReceiptItem.tsx b/app/features/receipt/ui/DownloadReceiptItem.tsx index 45f3dec79..3c914a8b2 100644 --- a/app/features/receipt/ui/DownloadReceiptItem.tsx +++ b/app/features/receipt/ui/DownloadReceiptItem.tsx @@ -2,7 +2,6 @@ import type { TransactionSignature } from '@solana/web3.js'; import type { ReactNode } from 'react'; -import { Check, Loader, XCircle } from 'react-feather'; import { EReceiptDownloadFormat, receiptAnalytics } from '@/app/shared/lib/analytics'; @@ -18,21 +17,14 @@ interface DownloadReceiptItemBaseProps { } export function DownloadReceiptItemBase({ icon, label, state, onTrigger }: DownloadReceiptItemBaseProps) { + const isDownloading = state === 'downloading'; + function getIcon() { - if (state === 'downloading') return ; - if (state === 'downloaded') return ; - if (state === 'errored') return ; + if (isDownloading) return