@sip-protocol/react 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,651 @@
1
+ import React, { useState, useCallback, useMemo } from 'react'
2
+ import type { HexString } from '@sip-protocol/types'
3
+ import { useTransactionHistory } from '../hooks/use-transaction-history'
4
+ import type { TransactionSummary } from '../hooks/use-transaction-history'
5
+
6
+ /**
7
+ * Transaction type for NEAR privacy operations
8
+ */
9
+ type NEARTransactionType = 'send' | 'receive' | 'contract_call'
10
+
11
+ /**
12
+ * Export format options
13
+ */
14
+ type NEARExportFormat = 'csv' | 'json'
15
+
16
+ /**
17
+ * Historical transaction structure
18
+ */
19
+ interface NEARHistoricalTransaction {
20
+ hash: string
21
+ timestamp: number
22
+ blockHeight: number
23
+ type: NEARTransactionType
24
+ stealthAddress: string
25
+ stealthPublicKey: HexString
26
+ ephemeralPublicKey: HexString
27
+ viewTag: number
28
+ amount: string
29
+ amountFormatted: string
30
+ token: string
31
+ tokenContract: string | null
32
+ decimals: number
33
+ privacyLevel: 'transparent' | 'shielded' | 'compliant'
34
+ amountRevealed: boolean
35
+ sender?: string
36
+ receiver?: string
37
+ fee?: string
38
+ explorerUrl: string
39
+ recipientLabel?: string
40
+ }
41
+
42
+ /**
43
+ * Transaction history view component props
44
+ */
45
+ export interface TransactionHistoryViewProps {
46
+ /** NEAR RPC URL */
47
+ rpcUrl: string
48
+ /** Viewing private key (hex) */
49
+ viewingPrivateKey: HexString
50
+ /** Spending private key (hex) */
51
+ spendingPrivateKey: HexString
52
+ /** Network type */
53
+ network?: 'mainnet' | 'testnet'
54
+ /** Number of transactions per page */
55
+ pageSize?: number
56
+ /** Auto-refresh interval in milliseconds (0 = disabled) */
57
+ refreshInterval?: number
58
+ /** Whether to show filter controls */
59
+ showFilters?: boolean
60
+ /** Whether to show export button */
61
+ showExport?: boolean
62
+ /** Whether to show summary statistics */
63
+ showSummary?: boolean
64
+ /** Whether to show search */
65
+ showSearch?: boolean
66
+ /** Callback when transaction is clicked */
67
+ onTransactionClick?: (tx: NEARHistoricalTransaction) => void
68
+ /** Callback when export is triggered */
69
+ onExport?: (format: NEARExportFormat, data: string) => void
70
+ /** Custom class name */
71
+ className?: string
72
+ /** Theme */
73
+ theme?: 'light' | 'dark'
74
+ }
75
+
76
+ /**
77
+ * Default styles for the component
78
+ */
79
+ const styles = {
80
+ container: {
81
+ fontFamily: 'system-ui, -apple-system, sans-serif',
82
+ borderRadius: '8px',
83
+ overflow: 'hidden',
84
+ },
85
+ header: {
86
+ padding: '16px',
87
+ borderBottom: '1px solid',
88
+ display: 'flex',
89
+ justifyContent: 'space-between',
90
+ alignItems: 'center',
91
+ },
92
+ title: {
93
+ fontSize: '18px',
94
+ fontWeight: 600,
95
+ margin: 0,
96
+ },
97
+ controls: {
98
+ display: 'flex',
99
+ gap: '8px',
100
+ alignItems: 'center',
101
+ },
102
+ filterBar: {
103
+ padding: '12px 16px',
104
+ borderBottom: '1px solid',
105
+ display: 'flex',
106
+ gap: '12px',
107
+ flexWrap: 'wrap' as const,
108
+ alignItems: 'center',
109
+ },
110
+ searchInput: {
111
+ padding: '8px 12px',
112
+ borderRadius: '6px',
113
+ border: '1px solid',
114
+ fontSize: '14px',
115
+ minWidth: '200px',
116
+ },
117
+ filterSelect: {
118
+ padding: '8px 12px',
119
+ borderRadius: '6px',
120
+ border: '1px solid',
121
+ fontSize: '14px',
122
+ cursor: 'pointer',
123
+ },
124
+ button: {
125
+ padding: '8px 16px',
126
+ borderRadius: '6px',
127
+ border: 'none',
128
+ fontSize: '14px',
129
+ fontWeight: 500,
130
+ cursor: 'pointer',
131
+ display: 'flex',
132
+ alignItems: 'center',
133
+ gap: '6px',
134
+ },
135
+ primaryButton: {
136
+ backgroundColor: '#3b82f6',
137
+ color: 'white',
138
+ },
139
+ secondaryButton: {
140
+ backgroundColor: 'transparent',
141
+ border: '1px solid',
142
+ },
143
+ list: {
144
+ maxHeight: '500px',
145
+ overflowY: 'auto' as const,
146
+ },
147
+ listItem: {
148
+ padding: '16px',
149
+ borderBottom: '1px solid',
150
+ cursor: 'pointer',
151
+ transition: 'background-color 0.15s',
152
+ },
153
+ listItemHover: {
154
+ backgroundColor: 'rgba(0,0,0,0.02)',
155
+ },
156
+ txRow: {
157
+ display: 'flex',
158
+ justifyContent: 'space-between',
159
+ alignItems: 'flex-start',
160
+ },
161
+ txLeft: {
162
+ display: 'flex',
163
+ flexDirection: 'column' as const,
164
+ gap: '4px',
165
+ },
166
+ txRight: {
167
+ display: 'flex',
168
+ flexDirection: 'column' as const,
169
+ alignItems: 'flex-end' as const,
170
+ gap: '4px',
171
+ },
172
+ txType: {
173
+ fontSize: '12px',
174
+ fontWeight: 500,
175
+ padding: '2px 8px',
176
+ borderRadius: '4px',
177
+ textTransform: 'uppercase' as const,
178
+ },
179
+ txTypeReceive: {
180
+ backgroundColor: '#dcfce7',
181
+ color: '#166534',
182
+ },
183
+ txTypeSend: {
184
+ backgroundColor: '#fee2e2',
185
+ color: '#991b1b',
186
+ },
187
+ txTypeCall: {
188
+ backgroundColor: '#e0e7ff',
189
+ color: '#3730a3',
190
+ },
191
+ txAmount: {
192
+ fontSize: '16px',
193
+ fontWeight: 600,
194
+ },
195
+ txToken: {
196
+ fontSize: '14px',
197
+ opacity: 0.7,
198
+ },
199
+ txHash: {
200
+ fontSize: '12px',
201
+ fontFamily: 'monospace',
202
+ opacity: 0.6,
203
+ },
204
+ txTime: {
205
+ fontSize: '12px',
206
+ opacity: 0.6,
207
+ },
208
+ privacyBadge: {
209
+ fontSize: '10px',
210
+ padding: '2px 6px',
211
+ borderRadius: '3px',
212
+ textTransform: 'uppercase' as const,
213
+ },
214
+ privacyShielded: {
215
+ backgroundColor: '#fef3c7',
216
+ color: '#92400e',
217
+ },
218
+ privacyCompliant: {
219
+ backgroundColor: '#dbeafe',
220
+ color: '#1e40af',
221
+ },
222
+ summary: {
223
+ padding: '16px',
224
+ borderBottom: '1px solid',
225
+ display: 'grid',
226
+ gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
227
+ gap: '16px',
228
+ },
229
+ summaryItem: {
230
+ display: 'flex',
231
+ flexDirection: 'column' as const,
232
+ gap: '4px',
233
+ },
234
+ summaryLabel: {
235
+ fontSize: '12px',
236
+ opacity: 0.6,
237
+ textTransform: 'uppercase' as const,
238
+ },
239
+ summaryValue: {
240
+ fontSize: '20px',
241
+ fontWeight: 600,
242
+ },
243
+ loading: {
244
+ padding: '40px',
245
+ textAlign: 'center' as const,
246
+ opacity: 0.6,
247
+ },
248
+ error: {
249
+ padding: '16px',
250
+ backgroundColor: '#fee2e2',
251
+ color: '#991b1b',
252
+ borderRadius: '6px',
253
+ margin: '16px',
254
+ },
255
+ empty: {
256
+ padding: '40px',
257
+ textAlign: 'center' as const,
258
+ opacity: 0.6,
259
+ },
260
+ footer: {
261
+ padding: '12px 16px',
262
+ borderTop: '1px solid',
263
+ display: 'flex',
264
+ justifyContent: 'space-between',
265
+ alignItems: 'center',
266
+ },
267
+ pagination: {
268
+ fontSize: '14px',
269
+ opacity: 0.7,
270
+ },
271
+ light: {
272
+ backgroundColor: '#ffffff',
273
+ color: '#1f2937',
274
+ borderColor: '#e5e7eb',
275
+ },
276
+ dark: {
277
+ backgroundColor: '#1f2937',
278
+ color: '#f9fafb',
279
+ borderColor: '#374151',
280
+ },
281
+ }
282
+
283
+ /**
284
+ * Format timestamp to readable date
285
+ */
286
+ function formatDate(timestamp: number): string {
287
+ return new Date(timestamp).toLocaleDateString(undefined, {
288
+ year: 'numeric',
289
+ month: 'short',
290
+ day: 'numeric',
291
+ hour: '2-digit',
292
+ minute: '2-digit',
293
+ })
294
+ }
295
+
296
+ /**
297
+ * Truncate hash for display
298
+ */
299
+ function truncateHash(hash: string): string {
300
+ if (hash.length <= 16) return hash
301
+ return `${hash.slice(0, 8)}...${hash.slice(-6)}`
302
+ }
303
+
304
+ /**
305
+ * Transaction list item component
306
+ */
307
+ interface TransactionItemProps {
308
+ transaction: NEARHistoricalTransaction
309
+ onClick?: (tx: NEARHistoricalTransaction) => void
310
+ theme: 'light' | 'dark'
311
+ }
312
+
313
+ function TransactionItem({ transaction, onClick, theme }: TransactionItemProps) {
314
+ const [isHovered, setIsHovered] = useState(false)
315
+ const themeStyles = theme === 'dark' ? styles.dark : styles.light
316
+
317
+ const typeStyleMap: Record<NEARTransactionType, React.CSSProperties> = {
318
+ receive: styles.txTypeReceive,
319
+ send: styles.txTypeSend,
320
+ contract_call: styles.txTypeCall,
321
+ }
322
+ const typeStyle = typeStyleMap[transaction.type]
323
+
324
+ const privacyStyle = transaction.privacyLevel === 'compliant'
325
+ ? styles.privacyCompliant
326
+ : styles.privacyShielded
327
+
328
+ return (
329
+ <div
330
+ style={{
331
+ ...styles.listItem,
332
+ borderColor: themeStyles.borderColor,
333
+ ...(isHovered ? styles.listItemHover : {}),
334
+ }}
335
+ onClick={() => onClick?.(transaction)}
336
+ onMouseEnter={() => setIsHovered(true)}
337
+ onMouseLeave={() => setIsHovered(false)}
338
+ >
339
+ <div style={styles.txRow}>
340
+ <div style={styles.txLeft}>
341
+ <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
342
+ <span style={{ ...styles.txType, ...typeStyle }}>
343
+ {transaction.type}
344
+ </span>
345
+ <span style={{ ...styles.privacyBadge, ...privacyStyle }}>
346
+ {transaction.privacyLevel}
347
+ </span>
348
+ </div>
349
+ <span style={styles.txHash} title={transaction.hash}>
350
+ {truncateHash(transaction.hash)}
351
+ </span>
352
+ <span style={styles.txTime}>
353
+ {formatDate(transaction.timestamp)}
354
+ </span>
355
+ </div>
356
+ <div style={styles.txRight}>
357
+ <span style={styles.txAmount}>
358
+ {transaction.type === 'send' ? '-' : '+'}{transaction.amountFormatted}
359
+ </span>
360
+ <span style={styles.txToken}>{transaction.token}</span>
361
+ </div>
362
+ </div>
363
+ </div>
364
+ )
365
+ }
366
+
367
+ /**
368
+ * Summary statistics component
369
+ */
370
+ interface SummaryViewProps {
371
+ summary: TransactionSummary
372
+ theme: 'light' | 'dark'
373
+ }
374
+
375
+ function SummaryView({ summary, theme }: SummaryViewProps) {
376
+ const themeStyles = theme === 'dark' ? styles.dark : styles.light
377
+
378
+ return (
379
+ <div style={{ ...styles.summary, borderColor: themeStyles.borderColor }}>
380
+ <div style={styles.summaryItem}>
381
+ <span style={styles.summaryLabel}>Transactions</span>
382
+ <span style={styles.summaryValue}>{summary.transactionCount}</span>
383
+ </div>
384
+ <div style={styles.summaryItem}>
385
+ <span style={styles.summaryLabel}>Total Received</span>
386
+ <span style={styles.summaryValue}>
387
+ {Object.entries(summary.totalReceived).map(([token, amount]) => (
388
+ <span key={token}>{amount.toString()} {token}</span>
389
+ ))}
390
+ {Object.keys(summary.totalReceived).length === 0 && '0'}
391
+ </span>
392
+ </div>
393
+ <div style={styles.summaryItem}>
394
+ <span style={styles.summaryLabel}>Addresses</span>
395
+ <span style={styles.summaryValue}>{summary.uniqueAddresses}</span>
396
+ </div>
397
+ {summary.dateRange && (
398
+ <div style={styles.summaryItem}>
399
+ <span style={styles.summaryLabel}>Date Range</span>
400
+ <span style={{ fontSize: '14px' }}>
401
+ {formatDate(summary.dateRange.from).split(',')[0]} - {formatDate(summary.dateRange.to).split(',')[0]}
402
+ </span>
403
+ </div>
404
+ )}
405
+ </div>
406
+ )
407
+ }
408
+
409
+ /**
410
+ * TransactionHistoryView - Display NEAR privacy transaction history
411
+ *
412
+ * A comprehensive component for viewing, filtering, and exporting
413
+ * NEAR privacy transaction history.
414
+ *
415
+ * @example Basic usage
416
+ * ```tsx
417
+ * <TransactionHistoryView
418
+ * rpcUrl="https://rpc.mainnet.near.org"
419
+ * viewingPrivateKey="0x..."
420
+ * spendingPrivateKey="0x..."
421
+ * />
422
+ * ```
423
+ *
424
+ * @example With callbacks
425
+ * ```tsx
426
+ * <TransactionHistoryView
427
+ * rpcUrl="https://rpc.mainnet.near.org"
428
+ * viewingPrivateKey="0x..."
429
+ * spendingPrivateKey="0x..."
430
+ * onTransactionClick={(tx) => openInExplorer(tx.explorerUrl)}
431
+ * onExport={(format, data) => downloadFile(`transactions.${format}`, data)}
432
+ * showFilters
433
+ * showExport
434
+ * showSummary
435
+ * />
436
+ * ```
437
+ */
438
+ export function TransactionHistoryView({
439
+ rpcUrl,
440
+ viewingPrivateKey,
441
+ spendingPrivateKey,
442
+ network = 'mainnet',
443
+ pageSize = 20,
444
+ refreshInterval = 0,
445
+ showFilters = true,
446
+ showExport = true,
447
+ showSummary = true,
448
+ showSearch = true,
449
+ onTransactionClick,
450
+ onExport,
451
+ className,
452
+ theme = 'light',
453
+ }: TransactionHistoryViewProps) {
454
+ const {
455
+ status,
456
+ isLoading,
457
+ isRefreshing,
458
+ error,
459
+ transactions,
460
+ hasMore,
461
+ totalCount,
462
+ lastRefreshedAt,
463
+ summary,
464
+ filters,
465
+ refresh,
466
+ loadMore,
467
+ setFilters,
468
+ clearFilters,
469
+ exportData,
470
+ search,
471
+ clearError,
472
+ } = useTransactionHistory({
473
+ rpcUrl,
474
+ viewingPrivateKey,
475
+ spendingPrivateKey,
476
+ network,
477
+ pageSize,
478
+ refreshInterval,
479
+ })
480
+
481
+ const [searchQuery, setSearchQuery] = useState('')
482
+ const [exportFormat, setExportFormat] = useState<NEARExportFormat>('csv')
483
+
484
+ const themeStyles = theme === 'dark' ? styles.dark : styles.light
485
+
486
+ const handleSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
487
+ const query = e.target.value
488
+ setSearchQuery(query)
489
+ // Debounce search
490
+ const timeoutId = setTimeout(() => {
491
+ search(query)
492
+ }, 300)
493
+ return () => clearTimeout(timeoutId)
494
+ }, [search])
495
+
496
+ const handleTypeFilter = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
497
+ const value = e.target.value
498
+ const typeFilter: NEARTransactionType[] | undefined = value ? [value as NEARTransactionType] : undefined
499
+ setFilters({ ...filters, typeFilter })
500
+ }, [filters, setFilters])
501
+
502
+ const handleExport = useCallback(() => {
503
+ const data = exportData(exportFormat, { prettyPrint: exportFormat === 'json' })
504
+ onExport?.(exportFormat, data)
505
+ }, [exportData, exportFormat, onExport])
506
+
507
+ const containerStyle = useMemo(() => ({
508
+ ...styles.container,
509
+ ...themeStyles,
510
+ border: `1px solid ${themeStyles.borderColor}`,
511
+ }), [themeStyles])
512
+
513
+ return (
514
+ <div style={containerStyle} className={className}>
515
+ {/* Header */}
516
+ <div style={{ ...styles.header, borderColor: themeStyles.borderColor }}>
517
+ <h2 style={styles.title}>Transaction History</h2>
518
+ <div style={styles.controls}>
519
+ {lastRefreshedAt && (
520
+ <span style={{ fontSize: '12px', opacity: 0.6 }}>
521
+ Last updated: {formatDate(lastRefreshedAt.getTime())}
522
+ </span>
523
+ )}
524
+ <button
525
+ style={{ ...styles.button, ...styles.secondaryButton, borderColor: themeStyles.borderColor }}
526
+ onClick={refresh}
527
+ disabled={isLoading || isRefreshing}
528
+ >
529
+ {isRefreshing ? 'Refreshing...' : 'Refresh'}
530
+ </button>
531
+ </div>
532
+ </div>
533
+
534
+ {/* Filter Bar */}
535
+ {showFilters && (
536
+ <div style={{ ...styles.filterBar, borderColor: themeStyles.borderColor }}>
537
+ {showSearch && (
538
+ <input
539
+ type="text"
540
+ placeholder="Search by hash or address..."
541
+ value={searchQuery}
542
+ onChange={handleSearch}
543
+ style={{ ...styles.searchInput, borderColor: themeStyles.borderColor, backgroundColor: themeStyles.backgroundColor }}
544
+ />
545
+ )}
546
+ <select
547
+ value={filters.typeFilter?.[0] || ''}
548
+ onChange={handleTypeFilter}
549
+ style={{ ...styles.filterSelect, borderColor: themeStyles.borderColor, backgroundColor: themeStyles.backgroundColor }}
550
+ >
551
+ <option value="">All Types</option>
552
+ <option value="receive">Receive</option>
553
+ <option value="send">Send</option>
554
+ <option value="contract_call">Contract Call</option>
555
+ </select>
556
+ {(filters.typeFilter || filters.searchQuery) && (
557
+ <button
558
+ style={{ ...styles.button, ...styles.secondaryButton, borderColor: themeStyles.borderColor }}
559
+ onClick={() => { clearFilters(); setSearchQuery('') }}
560
+ >
561
+ Clear Filters
562
+ </button>
563
+ )}
564
+ {showExport && transactions.length > 0 && (
565
+ <>
566
+ <select
567
+ value={exportFormat}
568
+ onChange={(e) => setExportFormat(e.target.value as NEARExportFormat)}
569
+ style={{ ...styles.filterSelect, borderColor: themeStyles.borderColor, backgroundColor: themeStyles.backgroundColor }}
570
+ >
571
+ <option value="csv">CSV</option>
572
+ <option value="json">JSON</option>
573
+ </select>
574
+ <button
575
+ style={{ ...styles.button, ...styles.primaryButton }}
576
+ onClick={handleExport}
577
+ >
578
+ Export
579
+ </button>
580
+ </>
581
+ )}
582
+ </div>
583
+ )}
584
+
585
+ {/* Summary */}
586
+ {showSummary && summary && summary.transactionCount > 0 && (
587
+ <SummaryView summary={summary} theme={theme} />
588
+ )}
589
+
590
+ {/* Error */}
591
+ {error && (
592
+ <div style={styles.error}>
593
+ <strong>Error:</strong> {error.message}
594
+ <button
595
+ style={{ marginLeft: '8px', textDecoration: 'underline', cursor: 'pointer', border: 'none', background: 'none', color: 'inherit' }}
596
+ onClick={clearError}
597
+ >
598
+ Dismiss
599
+ </button>
600
+ </div>
601
+ )}
602
+
603
+ {/* Loading */}
604
+ {isLoading && status !== 'refreshing' && (
605
+ <div style={styles.loading}>Loading transactions...</div>
606
+ )}
607
+
608
+ {/* Empty State */}
609
+ {!isLoading && transactions.length === 0 && !error && (
610
+ <div style={styles.empty}>
611
+ No transactions found.
612
+ {filters.searchQuery || filters.typeFilter ? ' Try adjusting your filters.' : ''}
613
+ </div>
614
+ )}
615
+
616
+ {/* Transaction List */}
617
+ {transactions.length > 0 && (
618
+ <div style={styles.list}>
619
+ {transactions.map(tx => (
620
+ <TransactionItem
621
+ key={tx.hash}
622
+ transaction={tx}
623
+ onClick={onTransactionClick}
624
+ theme={theme}
625
+ />
626
+ ))}
627
+ </div>
628
+ )}
629
+
630
+ {/* Footer */}
631
+ {transactions.length > 0 && (
632
+ <div style={{ ...styles.footer, borderColor: themeStyles.borderColor }}>
633
+ <span style={styles.pagination}>
634
+ Showing {transactions.length} of {totalCount} transactions
635
+ </span>
636
+ {hasMore && (
637
+ <button
638
+ style={{ ...styles.button, ...styles.primaryButton }}
639
+ onClick={loadMore}
640
+ disabled={isLoading}
641
+ >
642
+ {isLoading ? 'Loading...' : 'Load More'}
643
+ </button>
644
+ )}
645
+ </div>
646
+ )}
647
+ </div>
648
+ )
649
+ }
650
+
651
+ export default TransactionHistoryView