diff --git a/src/app/payment-destination/page.tsx b/src/app/payment-destination/page.tsx new file mode 100644 index 0000000..2c1c228 --- /dev/null +++ b/src/app/payment-destination/page.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { ChainCurrencySelector } from "@/components/chain-currency-selector"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { CHAIN_TO_ID } from "@/lib/constants/chains"; +import { TRPCReactProvider } from "@/trpc/react"; +import { useAppKit, useAppKitAccount } from "@reown/appkit/react"; +import { LogOut, Wallet } from "lucide-react"; +import { useState } from "react"; + +function PaymentDestinationContent() { + const { open } = useAppKit(); + const { address, isConnected } = useAppKitAccount(); + + const [chainId, setChainId] = useState(CHAIN_TO_ID.SEPOLIA); + const [tokenAddress, setTokenAddress] = useState( + undefined, + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + console.info({ + walletAddress: address, + chainId, + tokenAddress, + }); + }; + + return ( +
+
+

Payment Destination

+ + {/* Wallet Connection */} + + + Wallet Connection + + + {isConnected ? ( + <> +
+
Connected Address
+
+ {address} +
+
+ + + ) : ( +
+

+ Please connect your wallet to continue +

+ +
+ )} +
+
+ + {/* Chain and Currency Selection */} + {isConnected && ( + + + Payment Details + + +
+ { + setChainId(newChainId); + setTokenAddress(undefined); + }} + onTokenChange={setTokenAddress} + /> + + + +
+
+ )} +
+
+ ); +} + +export default function PaymentDestinationPage() { + return ( + + + + ); +} diff --git a/src/components/chain-currency-selector.tsx b/src/components/chain-currency-selector.tsx new file mode 100644 index 0000000..c0e03f3 --- /dev/null +++ b/src/components/chain-currency-selector.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { CHAIN_TO_ID, ID_TO_NETWORK } from "@/lib/constants/chains"; +import { api } from "@/trpc/react"; +import { Loader2 } from "lucide-react"; +import { useEffect } from "react"; + +interface ChainCurrencySelectorProps { + chainId: number; + tokenAddress: string | undefined; + onChainChange: (chainId: number) => void; + onTokenChange: (address: string | undefined) => void; + isLoading?: boolean; +} + +export function ChainCurrencySelector({ + chainId, + tokenAddress, + onChainChange, + onTokenChange, +}: ChainCurrencySelectorProps) { + const networkName = ID_TO_NETWORK[chainId]; + + const { + data: currenciesData, + isLoading, + isError, + refetch, + } = api.currency.getCurrenciesByNetwork.useQuery( + { network: networkName }, + { + enabled: !!networkName, + staleTime: 86400000, // 1 day + gcTime: 86400000, // 1 day + }, + ); + + const currencies = currenciesData?.conversionRoutes || []; + + // Auto-select first token when currencies load or chain changes + useEffect(() => { + if ( + currencies.length > 0 && + !currencies.find((c) => c.address === tokenAddress) + ) { + onTokenChange(currencies[0].address); + } + }, [currencies, tokenAddress, onTokenChange]); + + const chainOptions = [ + { id: CHAIN_TO_ID.SEPOLIA, name: "Sepolia" }, + { id: CHAIN_TO_ID.BASE, name: "Base" }, + { id: CHAIN_TO_ID.ETHEREUM, name: "Ethereum" }, + { id: CHAIN_TO_ID.ARBITRUM, name: "Arbitrum" }, + { id: CHAIN_TO_ID.OPTIMISM, name: "Optimism" }, + { id: CHAIN_TO_ID.POLYGON, name: "Polygon" }, + ]; + + return ( +
+ {/* Chain Selector */} +
+ + +
+ + {/* Token Selector */} +
+ + {isLoading ? ( +
+ + + Loading tokens... + +
+ ) : isError ? ( +
+
+ + Something went wrong + +
+ +
+ ) : currencies.length === 0 ? ( +
+ + No tokens available + +
+ ) : ( + + )} +
+
+ ); +} + +export function isChainCurrencySelectorLoading(chainId: number): boolean { + const networkName = ID_TO_NETWORK[chainId]; + const { isLoading } = api.currency.getCurrenciesByNetwork.useQuery( + { network: networkName }, + { + enabled: !!networkName, + staleTime: 86400000, + gcTime: 86400000, + }, + ); + return isLoading; +} diff --git a/src/lib/constants/chains.ts b/src/lib/constants/chains.ts index 664b0bb..b69f70a 100644 --- a/src/lib/constants/chains.ts +++ b/src/lib/constants/chains.ts @@ -16,6 +16,15 @@ export const CHAIN_TO_ID = { POLYGON: 137, }; +export const ID_TO_NETWORK: Record = { + 137: "matic", + 8453: "base", + 42161: "arbitrum-one", + 10: "optimism", + 1: "mainnet", + 11155111: "sepolia", +}; + export const NETWORK_TO_ID = { matic: 137, base: 8453, diff --git a/src/server/routers/currency.ts b/src/server/routers/currency.ts index ed6d9cb..4a1030b 100644 --- a/src/server/routers/currency.ts +++ b/src/server/routers/currency.ts @@ -14,6 +14,7 @@ export type ConversionCurrency = { network: string; }; +// TODO: Rename this to just Currency as it's used for both conversion routes and general currencies export interface GetConversionCurrenciesResponse { currencyId: string; network: string; @@ -61,4 +62,41 @@ export const currencyRouter = router({ }); } }), + getCurrenciesByNetwork: publicProcedure + .input( + z.object({ + network: z.string(), + }), + ) + .query(async ({ input }): Promise => { + const { network } = input; + + try { + const response: AxiosResponse = + await apiClient.get(`v2/currencies?network=${network}`); + + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const statusCode = error.response?.status; + const code = + statusCode === 404 + ? "NOT_FOUND" + : statusCode === 400 + ? "BAD_REQUEST" + : "INTERNAL_SERVER_ERROR"; + + throw new TRPCError({ + code, + message: error.response?.data?.message || error.message, + cause: error, + }); + } + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch currencies", + }); + } + }), });