this repo has no description
at main 209 lines 6.0 kB view raw
1import { useState, useEffect } from 'react'; 2import { getTransaction, formatToshis } from '../lib/api'; 3import type { Transaction } from '../lib/api'; 4import { Stack, Divider } from './ui/Layout'; 5import { 6 Terminal, 7 Section, 8 LabelValue, 9 DataTable, 10 DataRow, 11 ActionBar, 12 Message, 13} from './ui/Terminal'; 14 15interface Props { 16 txId: string; 17} 18 19const handleCache = new Map<string, string>(); 20 21async function resolveHandle(did: string): Promise<string> { 22 if (handleCache.has(did)) { 23 return handleCache.get(did)!; 24 } 25 26 try { 27 // For did:web, extract the domain directly 28 if (did.startsWith('did:web:')) { 29 const domain = did.replace('did:web:', ''); 30 handleCache.set(did, domain); 31 return domain; 32 } 33 34 // For did:plc, use PLC directory 35 if (did.startsWith('did:plc:')) { 36 const didRes = await fetch(`https://plc.directory/${did}`); 37 if (didRes.ok) { 38 const doc = await didRes.json(); 39 const handle = doc.alsoKnownAs?.[0]?.replace('at://', '') || did; 40 handleCache.set(did, handle); 41 return handle; 42 } 43 } 44 } catch { 45 // Fall back to DID 46 } 47 48 handleCache.set(did, did); 49 return did; 50} 51 52export default function TransactionDetail({ txId }: Props) { 53 const [tx, setTx] = useState<Transaction | null>(null); 54 const [loading, setLoading] = useState(true); 55 const [error, setError] = useState<string | null>(null); 56 const [resolvedHandles, setResolvedHandles] = useState<Map<string, string>>(new Map()); 57 58 useEffect(() => { 59 async function fetchTx() { 60 try { 61 const result = await getTransaction(txId); 62 setTx(result.transaction); 63 64 const dids = new Set<string>(); 65 result.transaction.outputs.forEach(o => dids.add(o.owner)); 66 // Handle both trigger types 67 const trigger = result.transaction.trigger; 68 if (trigger) { 69 if ('followerDid' in trigger) { 70 dids.add(trigger.followerDid); 71 } else if ('senderDid' in trigger) { 72 dids.add(trigger.senderDid); 73 } 74 } 75 76 const handles = new Map<string, string>(); 77 await Promise.all( 78 Array.from(dids).map(async did => { 79 const handle = await resolveHandle(did); 80 handles.set(did, handle); 81 }) 82 ); 83 setResolvedHandles(handles); 84 } catch (e) { 85 setError(e instanceof Error ? e.message : 'Failed to fetch transaction'); 86 } finally { 87 setLoading(false); 88 } 89 } 90 fetchTx(); 91 }, [txId]); 92 93 if (loading) { 94 return ( 95 <Terminal title="TRANSACTION"> 96 <Section> 97 <LabelValue label="STATUS" value="LOADING..." status="warning" /> 98 </Section> 99 </Terminal> 100 ); 101 } 102 103 if (error || !tx) { 104 return ( 105 <Terminal title="TRANSACTION NOT FOUND"> 106 <Section> 107 <Stack gap="sm"> 108 <LabelValue label="TXID" value={txId} /> 109 <Message type="error">{error || 'Transaction not found'}</Message> 110 </Stack> 111 </Section> 112 <Divider /> 113 <ActionBar> 114 <a href="/wallet" className="btn secondary">[BACK TO WALLET]</a> 115 <a href="/" className="btn secondary">[HOME]</a> 116 </ActionBar> 117 </Terminal> 118 ); 119 } 120 121 const formatDid = (did: string) => { 122 const handle = resolvedHandles.get(did); 123 if (handle && handle !== did) { 124 return `@${handle}`; 125 } 126 return did.length > 32 ? `${did.slice(0, 32)}...` : did; 127 }; 128 129 const totalOutput = tx.outputs.reduce((sum, o) => sum + o.amount, 0); 130 131 return ( 132 <Terminal title="TRANSACTION DETAILS"> 133 <Section title="INFO"> 134 <Stack gap="xs"> 135 <LabelValue label="TXID" value={txId} /> 136 <LabelValue label="TYPE" value={tx.type.toUpperCase()} /> 137 <LabelValue label="DATE" value={new Date(tx.createdAt).toLocaleString()} /> 138 {tx.trigger && 'followerDid' in tx.trigger && ( 139 <> 140 <LabelValue label="TRIGGER" value="Follow event" /> 141 <LabelValue label="FROM" value={formatDid((tx.trigger as { followerDid: string }).followerDid)} /> 142 </> 143 )} 144 {tx.trigger && 'type' in tx.trigger && (tx.trigger as { type: string }).type === 'cashtag' && ( 145 <> 146 <LabelValue label="TRIGGER" value="$attoshi cashtag" /> 147 <LabelValue label="FROM" value={formatDid((tx.trigger as { senderDid: string }).senderDid)} /> 148 </> 149 )} 150 </Stack> 151 </Section> 152 153 <Divider /> 154 155 <Section title="RECIPIENTS"> 156 <DataTable 157 headers={['#', 'AMOUNT', 'TO']} 158 widths={['2rem', '8rem', '1fr']} 159 > 160 {tx.outputs.map((output, i) => ( 161 <DataRow 162 key={i} 163 cells={[ 164 String(i + 1), 165 `${formatToshis(output.amount)} @toshis`, 166 formatDid(output.owner), 167 ]} 168 widths={['2rem', '8rem', '1fr']} 169 /> 170 ))} 171 </DataTable> 172 <LabelValue 173 label="TOTAL" 174 value={`${formatToshis(totalOutput)} @toshis`} 175 /> 176 </Section> 177 178 {tx.type === 'transfer' && tx.inputs.length > 0 && ( 179 <> 180 <Divider /> 181 <Section title="SPENT COINS"> 182 <DataTable 183 headers={['#', 'SOURCE TX']} 184 widths={['2rem', '1fr']} 185 > 186 {tx.inputs.map((input, i) => ( 187 <DataRow 188 key={i} 189 cells={[ 190 String(i + 1), 191 `${input.txId}:${input.index}`, 192 ]} 193 widths={['2rem', '1fr']} 194 /> 195 ))} 196 </DataTable> 197 </Section> 198 </> 199 )} 200 201 <Divider /> 202 203 <ActionBar> 204 <a href="/wallet" className="btn secondary">[BACK TO WALLET]</a> 205 <a href="/" className="btn secondary">[HOME]</a> 206 </ActionBar> 207 </Terminal> 208 ); 209}