diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..41583e3 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@jsr:registry=https://npm.jsr.io diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 906d7c4..cd5c31f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import { Navbar } from "@/components/layout/Navbar"; import { Footer } from "@/components/layout/Footer"; import { ReAuthModal } from "@/components/ReAuthModal"; import "./globals.css"; +import { WalletProvider } from "../contexts/WalletContext"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -58,4 +59,4 @@ export default function RootLayout({ ); -} +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 95e4fa8..2523f61 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,6 +5,31 @@ import TrustAndHowItWorksSection from '@/components/landing/TrustAndHowItWorksSe import Link from 'next/link'; export default function Home() { + const { + status, + address, + selectedWallet, + wallets, + error, + isConnecting, + isConnected, + hasError, + isRejected, + connect, + } = useWalletConnection(); + + const [showError, setShowError] = useState(false); + + useEffect(() => { + if (isRejected) { + setShowError(true); + } + }, [isRejected]); + + const handleConnect = async (walletId: string) => { + await connect(walletId); + }; + return (
diff --git a/src/components/WalletModal.tsx b/src/components/WalletModal.tsx new file mode 100644 index 0000000..76ee645 --- /dev/null +++ b/src/components/WalletModal.tsx @@ -0,0 +1,148 @@ +import { useWalletConnection } from "../contexts/WalletContext"; +import { useState, useEffect } from "react"; + +type WalletWallet = { + id: string; + name: string; + icon: string; + installed: boolean; +}; + +interface ModalProps { + open: boolean; + onClose: () => void; + onConnect: (walletId: string) => void; +} + +export const WalletModal = ({ open, onClose, onConnect }: ModalProps) => { + const { + status, + address, + selectedWallet, + wallets, + error, + isConnecting, + isConnected, + hasError, + isRejected, + } = useWalletConnection(); + + const [showError, setShowError] = useState(false); + + useEffect(() => { + if (isRejected) { + setShowError(true); + } + if (isConnected) { + onClose(); + } + }, [isRejected, isConnected, onClose]); + + if (!open) return null; + + if (isConnecting) { + return ( +
+ ); + } + + if (hasError && error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + if (wallets.length === 0) { + return ( +
+
+

No wallets detected. Please install a Stellar wallet.

+ +
+
+ ); + } + + return ( +
+
+

+ Connect Your Wallet +

+ + {error && showError ? ( +

{error}

+ ) : null} + + {isConnecting ? ( +

Connecting...

+ ) : wallets.map((wallet) => ( +
onConnect(wallet.id)} + tabindex={0} + onKeyPress={(e) => e.key === "Enter" && onConnect(wallet.id)} + > + {wallet.name} + {wallet.name} + {wallet.installed ? null : ( + + Install + + )} +
+ ))} + + {!isConnected && !isConnecting ? ( + + ) : isConnected && address ? ( +

+ Connected: {address.slice(0, 6)}...{address.slice(-4)} +

+ ) : null} +
+
+ ); +}; \ No newline at end of file diff --git a/src/contexts/WalletContext.tsx b/src/contexts/WalletContext.tsx new file mode 100644 index 0000000..f8e7cac --- /dev/null +++ b/src/contexts/WalletContext.tsx @@ -0,0 +1,237 @@ +import { createContext, useContext, useReducer, useEffect, ReactNode, Dispatch } from "react"; + +type WalletStatus = "idle" | "connecting" | "connected" | "error" | "rejected"; + +interface WalletWallet { + id: string; + name: string; + icon: string; + installed: boolean; +} + +interface WalletState { + status: WalletStatus; + address: string | null; + selectedWallet: WalletWallet | null; + wallets: WalletWallet[]; + error: string | null; +} + +interface WalletAction { + type: + | "set_status" + | "set_address" + | "set_selected_wallet" + | "set_wallets" + | "set_error"; + payload: any; +} + +const initialState: WalletState = { + status: "idle", + address: null, + selectedWallet: null, + wallets: [], + error: null, +}; + +const WalletContext = createContext<{ + state: WalletState; + dispatch: Dispatch; + connect: (walletId: string) => Promise; +} | null>(null); + +export const useWallet = (): { + state: WalletState; + dispatch: Dispatch; + connect: (walletId: string) => Promise; +} => { + const ctx = useContext(WalletContext); + if (!ctx) { + throw new Error("useWallet must be used within a WalletProvider"); + } + return ctx; +}; + +type ReducerAction = + | { type: "set_status"; payload: WalletStatus } + | { type: "set_address"; payload: string | null } + | { type: "set_selected_wallet"; payload: WalletWallet | null } + | { type: "set_wallets"; payload: WalletWallet[] } + | { type: "set_error"; payload: string | null }; + +const walletReducer = (state: WalletState, action: ReducerAction): WalletState => { + switch (action.type) { + case "set_status": + return { ...state, status: action.payload }; + case "set_address": + return { ...state, address: action.payload }; + case "set_selected_wallet": + return { ...state, selectedWallet: action.payload }; + case "set_wallets": + return { ...state, wallets: action.payload }; + case "set_error": + return { ...state, error: action.payload }; + default: + return state; + } +}; + +interface WalletModule { + freighter?: boolean; + lobstr?: boolean; + xbull?: boolean; + albedo?: boolean; +} + +export const WalletProvider = ({ + children, +}: { + children: ReactNode; +}) => { + const [state, dispatch] = useReducer(walletReducer, initialState); + + useEffect(() => { + import("@creit-tech/stellar-wallets-kit/sdk").then((module) => { + import("@creit-tech/stellar-wallets-kit/modules/utils").then( + ({ defaultModules }) => { + const StellarWalletsKit = module.StellarWalletsKit; + StellarWalletsKit.init({ modules: defaultModules() }); + + const detectWallets = async () => { + try { + dispatch({ type: "set_status", payload: "connecting" }); + + const modules: WalletModule = await StellarWalletsKit.getModules(); + const wallets: WalletWallet[] = []; + + if (modules.freighter) { + wallets.push({ + id: "freighter", + name: "Freighter", + icon: "/freighter.svg", + installed: true, + }); + } else { + wallets.push({ + id: "freighter", + name: "Freighter", + icon: "/freighter.svg", + installed: false, + }); + } + + if (modules.lobstr) { + wallets.push({ + id: "lobstr", + name: "LOBSTR", + icon: "/lobstr.svg", + installed: true, + }); + } else { + wallets.push({ + id: "lobstr", + name: "LOBSTR", + icon: "/lobstr.svg", + installed: false, + }); + } + + if (modules.xbull) { + wallets.push({ + id: "xbull", + name: "xBull", + icon: "/xbull.svg", + installed: true, + }); + } else { + wallets.push({ + id: "xbull", + name: "xBull", + icon: "/xbull.svg", + installed: false, + }); + } + + if (modules.albedo) { + wallets.push({ + id: "albedo", + name: "Albedo", + icon: "/albedo.svg", + installed: true, + }); + } else { + wallets.push({ + id: "albedo", + name: "Albedo", + icon: "/albedo.svg", + installed: false, + }); + } + + dispatch({ type: "set_wallets", payload: wallets }); + dispatch({ type: "set_status", payload: "idle" }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to detect wallets"; + dispatch({ type: "set_error", payload: message }); + dispatch({ type: "set_status", payload: "idle" }); + } + }; + + detectWallets(); + } + ); + }); + }, []); + + const connect = async (walletId: string) => { + dispatch({ type: "set_status", payload: "connecting" }); + dispatch({ type: "set_error", payload: null }); + + try { + const StellarWalletsKit = (await import( + "@creit-tech/stellar-wallets-kit/sdk + ")).StellarWalletsKit; + + const { address } = await StellarWalletsKit.getAddress(); + + const wallet = state.wallets.find((w) => w.id === walletId) || null; + + dispatch({ type: "set_address", payload: address }); + dispatch({ type: "set_selected_wallet", payload: wallet }); + dispatch({ type: "set_status", payload: "connected" }); + } catch (err) { + const message = err instanceof Error ? err.message : "Connection failed"; + dispatch({ type: "set_error", payload: message }); + + if (message.includes("user rejected") || message.includes("cancel")) { + dispatch({ type: "set_status", payload: "rejected" }); + } else { + dispatch({ type: "set_status", payload: "error" }); + } + } + }; + + return ( + + {children} + + ); +}; + +export const useWalletConnection = () => { + const { state } = useWallet(); + + return { + status: state.status, + address: state.address, + selectedWallet: state.selectedWallet, + wallets: state.wallets, + error: state.error, + isConnecting: state.status === "connecting", + isConnected: state.status === "connected", + hasError: state.status === "error", + isRejected: state.status === "rejected", + }; +}; \ No newline at end of file