diff --git a/app/api/account/[chainId]/[address]/route.ts b/app/api/account/[chainId]/[address]/route.ts new file mode 100644 index 0000000..e5b2de0 --- /dev/null +++ b/app/api/account/[chainId]/[address]/route.ts @@ -0,0 +1,74 @@ +import { getDaimoAccountHistory } from '@/app/utils/accountHistory/getDaimoAccountHistory'; +import { resolveAccountForAddress } from '@/app/utils/profiles'; +import { AddressProfile } from '@/app/utils/types'; +import { createViemClient } from '@/app/utils/viem/client'; +import { Address, Hex } from 'viem'; + +// TransferLog type from Daimo API. +// TODO: add type when Daimo API is updated. +type TransferLog = { + type: string; + status: string; + timestamp: number; + from: string; + to: string; + amount: number; + blockNumber: number; + blockHash: string; + txHash: string; + logIndex: number; + nonceMetadata: string; + opHash: string; + requestStatus: string | null; + feeAmount: number; +}; + +export type TransferHistoryEntry = { + transferLog: TransferLog; + otherAccountProfile: AddressProfile; +}; + +/** + * Handle GET requests to /api/account/[chainId]/[address] + * + * @param {Object} params - The request parameters. + * @param {string} params.chainId - The chain ID of the desired account. + * @param {string} params.address - The address of the desired account. + * @returns {Object} The account profile in the form: { account: AccountProfile }. + */ +export async function GET( + req: Request, + { params }: { params: { chainId: string; address: string } }, +) { + const publicClient = createViemClient(params.chainId); + + const accountHistory = await getDaimoAccountHistory(params.address as Address); + const accountProfile: AddressProfile = await resolveAccountForAddress( + params.address as Hex, + publicClient, + ); + + // Filter out non-transfer logs. + const accountTransfers: TransferLog[] = accountHistory + ? accountHistory.transferLogs.filter((log: TransferLog) => log.type === 'transfer') + : []; + + // Get other account profile for each transfer. + const transfers: TransferHistoryEntry[] = await Promise.all( + accountTransfers.map(async (transferLog) => { + const otherAccountAddress = + params.address == transferLog.from ? transferLog.to : transferLog.from; + const otherAccountProfile: AddressProfile = await resolveAccountForAddress( + otherAccountAddress as Address, + publicClient, + ); + + return { transferLog: transferLog, otherAccountProfile: otherAccountProfile }; + }), + ); + + return Response.json({ + accountProfile: accountProfile, + accountTransferHistory: transfers, + }); +} diff --git a/app/components/AddressBubble.tsx b/app/components/AddressBubble.tsx index 49c03bd..3e0b4d8 100644 --- a/app/components/AddressBubble.tsx +++ b/app/components/AddressBubble.tsx @@ -5,6 +5,7 @@ import { TextInitial } from './typography'; import { AccountIcon } from '@/public/profileIcons/profileIcons'; import { AccountAddress, AccountName } from './typography'; import { getProfileLink } from '../utils/profiles/getProfileLink'; +import CopyText from './minorComponents/CopyText'; /** Header-value React component field for Address Bubble */ function AddressField(props: { name: string; address: string; accountType: AccountTypeStr }) { @@ -18,18 +19,23 @@ function AddressField(props: { name: string; address: string; accountType: Accou - - {props.address} +
+ {props.address} + +
); } -/** Represents an address bubble component given an address profile*/ -export default function AddressBubble(props: Readonly<{ addressProfile: AddressProfile }>) { +/** Represents an address bubble component given an address profile for transfers*/ +export default function AddressBubble( + props: Readonly<{ addressProfile: AddressProfile; link?: boolean }>, +) { const address = truncateAddress(props.addressProfile.accountAddress); const pfp = props.addressProfile.account?.avatar ?? null; const name = props.addressProfile.account?.name; const nameInitial = name ? name[0].toUpperCase() : '0x'; + return (
diff --git a/app/components/AddressBubbleRow.tsx b/app/components/AddressBubbleRow.tsx new file mode 100644 index 0000000..bd8f10f --- /dev/null +++ b/app/components/AddressBubbleRow.tsx @@ -0,0 +1,52 @@ +import { AccountIcon } from '@/public/profileIcons/profileIcons'; +import { truncateAddress } from '../utils/formatting'; +import { AccountTypeStr, AddressProfile } from '../utils/types'; +import { RowAccountProfileInitial } from './typography'; +import Image from 'next/image'; + +function AddressField(props: { name: string; address: string; accountType: AccountTypeStr }) { + // Get profile link for an account name. + return ( +
+
+ {props.accountType == AccountTypeStr.UNKNOWN ? props.address : props.name} + +
+
+ ); +} + +/** Represents an address bubble component given an address profile for transfer history */ +export default function AddressBubbleRow( + props: Readonly<{ addressProfile: AddressProfile; link?: boolean }>, +) { + const address = truncateAddress(props.addressProfile.accountAddress); + const pfp = props.addressProfile.account?.avatar ?? null; + const name = props.addressProfile.account?.name; + const nameInitial = name ? name[0].toUpperCase() : '0x'; + + return ( +
+
+ {pfp ? ( +
+ pfp +
+ ) : ( +
+
+ {nameInitial} +
+
+ )} +
+
+ +
+
+ ); +} diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index 13a0eed..0a976b2 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -5,7 +5,7 @@ import { Daimo } from '@/public/profileIcons/Daimo'; /** Footer component for Eth Receipts */ export default function Footer() { return ( -
+
diff --git a/app/components/TransferCard.tsx b/app/components/TransferCard.tsx index 7636520..cea91db 100644 --- a/app/components/TransferCard.tsx +++ b/app/components/TransferCard.tsx @@ -6,10 +6,9 @@ import TransferArrow from './minorComponents/TransferArrow'; import { NeueMontreal } from '@/public/fonts'; import { formatValue } from '../utils/formatting'; import stablecoinsAddresses from '../utils/tokens/stablecoins'; -import CopyReceipt from './minorComponents/CopyReceipt'; import { checkTokenWhitelist } from '../utils/tokens/tokenWhitelist'; -import { Warning } from '@/public/icons'; import TokenWarning from './minorComponents/TokenWarning'; +import CopyText from './minorComponents/CopyText'; /** * Represents an ERC20 Transfer card. @@ -49,7 +48,7 @@ export default function TransferCard(
- +
diff --git a/app/components/minorComponents/CopyReceipt.tsx b/app/components/minorComponents/CopyText.tsx similarity index 69% rename from app/components/minorComponents/CopyReceipt.tsx rename to app/components/minorComponents/CopyText.tsx index 67da68c..b1fac03 100644 --- a/app/components/minorComponents/CopyReceipt.tsx +++ b/app/components/minorComponents/CopyText.tsx @@ -3,14 +3,14 @@ import { Link } from '@/public/icons'; import { useState } from 'react'; -/* Component for copying receipt link to clipboard. */ -export default function CopyReceipt({ link }: { link: string }) { +/* Component for copying text to clipboard. */ +export default function CopyText({ text, hoverText }: { text: string; hoverText: string }) { const [copied, setCopied] = useState(false); // Copies link to clipboard. const copyLink = () => { setCopied(true); - navigator.clipboard.writeText(link); + navigator.clipboard.writeText(text); // Reset after 1 second. setTimeout(() => { @@ -23,9 +23,8 @@ export default function CopyReceipt({ link }: { link: string }) { -
-
{copied ? 'Copied!' : 'Copy'}
- {!copied &&
link
} +
+
{copied ? 'Copied!' : hoverText}
diff --git a/app/components/minorComponents/SearchBar.tsx b/app/components/minorComponents/SearchBar.tsx new file mode 100644 index 0000000..18ee4aa --- /dev/null +++ b/app/components/minorComponents/SearchBar.tsx @@ -0,0 +1,16 @@ +import { Search } from '@/public/icons'; + +export default function SearchBar() { + return ( +
+ +
+ +
+
+ ); +} diff --git a/app/components/typography.tsx b/app/components/typography.tsx index 4d04b88..9c2780d 100644 --- a/app/components/typography.tsx +++ b/app/components/typography.tsx @@ -84,7 +84,7 @@ export function Header({ children }: { children: React.ReactNode }) { // Text for footer. export function TextFooter({ children }: { children: React.ReactNode }) { return ( -

+

{children}

); @@ -98,3 +98,48 @@ export function LinkFooter({ href, children }: { href: string; children: React.R ); } + +/** Typography for profile search */ + +// Header for account activity on profile. +export function HeaderProfileActivity({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +// Row header for profile table. +export function RowHeaderProfileActivity({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +// Transfers made to/from a specific account. +export function RowValueProfileActivity({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +export function RowTimeProfileActivity({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +export function RowAccountProfileInitial({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} diff --git a/app/env.ts b/app/env.ts index 42077e1..08e187a 100644 --- a/app/env.ts +++ b/app/env.ts @@ -1,3 +1,5 @@ import { DaimoChain, getChainConfig } from './utils/viem/chainConfig'; export const chainConfig = getChainConfig((process.env.DAIMO_CHAIN || 'baseSepolia') as DaimoChain); + +export const apiUrl = process.env.ETH_RECEIPTS_DOMAIN || 'http://localhost:3000'; diff --git a/app/l/[chainId]/[blockNumber]/[logIndex]/page.tsx b/app/l/[chainId]/[blockNumber]/[logIndex]/page.tsx index c97a62a..e6638d8 100644 --- a/app/l/[chainId]/[blockNumber]/[logIndex]/page.tsx +++ b/app/l/[chainId]/[blockNumber]/[logIndex]/page.tsx @@ -2,8 +2,7 @@ import Footer from '@/app/components/Footer'; import LogNotFound from '@/app/components/LogNotFound'; import TransferCard from '@/app/components/TransferCard'; import { Header } from '@/app/components/typography'; - -const apiUrl = process.env.ETH_RECEIPTS_DOMAIN || 'http://localhost:3000'; +import { apiUrl } from '@/app/env'; /** * Fetch log data from API. @@ -58,7 +57,6 @@ export default async function Page({ ) : ( )} -
); } diff --git a/app/l/account/[chainId]/[address]/page.tsx b/app/l/account/[chainId]/[address]/page.tsx new file mode 100644 index 0000000..4f4a31f --- /dev/null +++ b/app/l/account/[chainId]/[address]/page.tsx @@ -0,0 +1,143 @@ +import { TransferHistoryEntry } from '@/app/api/account/[chainId]/[address]/route'; +import AddressBubble from '@/app/components/AddressBubble'; +import AddressBubbleRow from '@/app/components/AddressBubbleRow'; +import { + Header, + HeaderProfileActivity, + RowHeaderProfileActivity, + RowTimeProfileActivity, + RowValueProfileActivity, +} from '@/app/components/typography'; +import { apiUrl } from '@/app/env'; +import { formatValue, getDateDifference, truncateAddress } from '@/app/utils/formatting'; +import { AddressProfile } from '@/app/utils/types'; +import { USDC } from '@/public/tokens'; + +// Acount profile and transfer history. +type Account = { + accountProfile: AddressProfile; + accountTransferHistory: any[]; +}; + +/** + * Retrieve address profile given chainId and address. + * @returns {AddressProfile, Transfer[]} - The address profile and transfer history. + */ +async function getAddressProfile(chainId: string, address: string): Promise { + const res = await fetch(`${apiUrl}/api/account/${chainId}/${address}`); + if (!res.ok) { + console.error('Failed to fetch address profile'); + return null; + } + return res.json(); +} + +/** Make a table of transfers. */ +// TODO: change any[] +function makeTransferTable(accountAddress: string, transfers: any[]) { + const USDC_DECIMAL = 6; // TODO: get decimals from token + return ( +
+
+
+ Account +
+
+ Amount +
+
+ Token +
+
+ Time +
+
+ {transfers.toReversed().map((transfer: TransferHistoryEntry) => { + // List out all transfers to/from this account. + const sent = transfer.transferLog.from === accountAddress; + const value = formatValue( + Number(transfer.transferLog.amount) / Number(10 ** Number(USDC_DECIMAL)), + ); + + return ( +
+
+ + + +
+
+ + {sent ? `-$${value}` : `+$${value}`} + +
+
+ + {`${value} USDC`} +
+
+ + {getDateDifference(new Date(Number(transfer.transferLog.timestamp) * 1000), false)} + +
+
+ ); + })} +
+ ); +} + +/** + * Represents an account page for the account of blockNumber, index logIndex. + * + * @component + * @param {Object} params - The component parameters. + * @param {string} params.chainId - The chain ID. + * @param {string} params.address - The address of desired account. + * @returns {React.ReactElement} An account page component. + */ +export default async function Page({ params }: { params: { chainId: string; address: string } }) { + const addressProfile: Account | null = await getAddressProfile(params.chainId, params.address); + if (!addressProfile) return null; + + const accountTransferHistory = addressProfile.accountTransferHistory; + + return ( +
+
+
ETH RECEIPT
+
+
+
+
+ +
+
+ +
+
+
+ Recent activity +
+ {makeTransferTable( + addressProfile.accountProfile.accountAddress, + accountTransferHistory, + )} +
+
+
+
+ ); +} diff --git a/app/l/search/page.tsx b/app/l/search/page.tsx new file mode 100644 index 0000000..3cb4f57 --- /dev/null +++ b/app/l/search/page.tsx @@ -0,0 +1,14 @@ +import Footer from '@/app/components/Footer'; +import SearchBar from '@/app/components/minorComponents/SearchBar'; +import { Header } from '@/app/components/typography'; + +export default function Page() { + return ( +
+
+
ETH RECEIPT
+
+ +
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 85342a4..1fd71d0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,10 +1,13 @@ import { sfPro } from '@/public/fonts'; import './globals.css'; +import Footer from './components/Footer'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - {children} + + + {children}