From e047b8c64cd41064cc81f4d4ee620600b6a2540c Mon Sep 17 00:00:00 2001 From: ElromEvedElElyon Date: Sun, 22 Mar 2026 02:22:45 -0300 Subject: [PATCH] feat: add Testnet/Mainnet network selector with persistence - Closes #80 --- src/app/providers.tsx | 25 +++--- src/components/Navbar.tsx | 6 +- src/components/NetworkSelector.tsx | 139 +++++++++++++++++++++++++++++ src/contexts/NetworkContext.tsx | 65 ++++++++++++++ src/lib/network.ts | 77 ++++++++++++++++ src/lib/sorosave.ts | 34 ++++--- 6 files changed, 323 insertions(+), 23 deletions(-) create mode 100644 src/components/NetworkSelector.tsx create mode 100644 src/contexts/NetworkContext.tsx create mode 100644 src/lib/network.ts diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 98f8bf5..30acadb 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useCallback, useEffect } from "react"; import { connectWallet, getPublicKey, isFreighterInstalled } from "@/lib/wallet"; +import { NetworkProvider } from "@/contexts/NetworkContext"; interface WalletContextType { address: string | null; @@ -45,16 +46,18 @@ export function Providers({ children }: { children: React.ReactNode }) { }, []); return ( - - {children} - + + + {children} + + ); } diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 2d673aa..7aa1305 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { ConnectWallet } from "./ConnectWallet"; +import { NetworkSelector } from "./NetworkSelector"; export function Navbar() { return ( @@ -27,7 +28,10 @@ export function Navbar() { - +
+ + +
diff --git a/src/components/NetworkSelector.tsx b/src/components/NetworkSelector.tsx new file mode 100644 index 0000000..3816520 --- /dev/null +++ b/src/components/NetworkSelector.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState } from "react"; +import { useNetwork } from "@/contexts/NetworkContext"; +import { getAllNetworks, NetworkId } from "@/lib/network"; + +/** + * Dropdown network selector displayed in the Navbar. + * Shows the current network with a colored indicator dot and allows + * switching between Testnet and Mainnet with a confirmation dialog. + */ +export function NetworkSelector() { + const { networkId, switchNetwork } = useNetwork(); + const [pendingNetwork, setPendingNetwork] = useState(null); + const [isOpen, setIsOpen] = useState(false); + const networks = getAllNetworks(); + + const indicatorColor = + networkId === "mainnet" ? "bg-green-500" : "bg-yellow-500"; + + function handleSelect(id: NetworkId) { + setIsOpen(false); + if (id === networkId) return; + setPendingNetwork(id); + } + + function confirmSwitch() { + if (pendingNetwork) { + switchNetwork(pendingNetwork); + setPendingNetwork(null); + } + } + + function cancelSwitch() { + setPendingNetwork(null); + } + + return ( + <> + {/* Dropdown trigger */} +
+ + + {isOpen && ( +
+ {networks.map((n) => ( + + ))} +
+ )} +
+ + {/* Confirmation dialog */} + {pendingNetwork && ( +
+
+

+ Switch Network +

+

+ Are you sure you want to switch to{" "} + + {pendingNetwork === "mainnet" + ? "Stellar Mainnet" + : "Stellar Testnet"} + + ? Cached data will be cleared and the page will reload with the + new network configuration. +

+
+ + +
+
+
+ )} + + ); +} diff --git a/src/contexts/NetworkContext.tsx b/src/contexts/NetworkContext.tsx new file mode 100644 index 0000000..9a4769e --- /dev/null +++ b/src/contexts/NetworkContext.tsx @@ -0,0 +1,65 @@ +"use client"; + +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, +} from "react"; +import { + NetworkId, + NetworkConfig, + getNetworkConfig, + loadSelectedNetwork, + saveSelectedNetwork, +} from "@/lib/network"; + +interface NetworkContextType { + /** The currently active network id. */ + networkId: NetworkId; + /** Full config object for the active network. */ + network: NetworkConfig; + /** Switch to a different network. Persists the choice and clears cached data. */ + switchNetwork: (id: NetworkId) => void; +} + +const NetworkContext = createContext({ + networkId: "testnet", + network: getNetworkConfig("testnet"), + switchNetwork: () => {}, +}); + +export function useNetwork() { + return useContext(NetworkContext); +} + +export function NetworkProvider({ children }: { children: React.ReactNode }) { + const [networkId, setNetworkId] = useState("testnet"); + + // Load persisted selection on mount (client-only) + useEffect(() => { + setNetworkId(loadSelectedNetwork()); + }, []); + + const switchNetwork = useCallback( + (id: NetworkId) => { + if (id === networkId) return; + saveSelectedNetwork(id); + setNetworkId(id); + // Clear any cached contract / group data so the UI re-fetches for the new network + if (typeof window !== "undefined") { + sessionStorage.clear(); + } + }, + [networkId], + ); + + const network = getNetworkConfig(networkId); + + return ( + + {children} + + ); +} diff --git a/src/lib/network.ts b/src/lib/network.ts new file mode 100644 index 0000000..29a5ead --- /dev/null +++ b/src/lib/network.ts @@ -0,0 +1,77 @@ +/** + * Network configuration for Stellar Testnet and Mainnet. + * Provides network definitions, persistence, and helpers for switching + * between networks in the SoroSave frontend. + */ + +export type NetworkId = "testnet" | "mainnet"; + +export interface NetworkConfig { + id: NetworkId; + name: string; + rpcUrl: string; + networkPassphrase: string; + horizonUrl: string; + contractId: string; +} + +const NETWORKS: Record = { + testnet: { + id: "testnet", + name: "Testnet", + rpcUrl: + process.env.NEXT_PUBLIC_TESTNET_RPC_URL || + "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + horizonUrl: "https://horizon-testnet.stellar.org", + contractId: process.env.NEXT_PUBLIC_TESTNET_CONTRACT_ID || "", + }, + mainnet: { + id: "mainnet", + name: "Mainnet", + rpcUrl: + process.env.NEXT_PUBLIC_MAINNET_RPC_URL || + "https://soroban.stellar.org", + networkPassphrase: "Public Global Stellar Network ; September 2015", + horizonUrl: "https://horizon.stellar.org", + contractId: process.env.NEXT_PUBLIC_MAINNET_CONTRACT_ID || "", + }, +}; + +const STORAGE_KEY = "sorosave_selected_network"; + +/** + * Retrieve the full config for a given network ID. + */ +export function getNetworkConfig(id: NetworkId): NetworkConfig { + return NETWORKS[id]; +} + +/** + * Return all available networks. + */ +export function getAllNetworks(): NetworkConfig[] { + return Object.values(NETWORKS); +} + +/** + * Persist the selected network in localStorage. + */ +export function saveSelectedNetwork(id: NetworkId): void { + if (typeof window !== "undefined") { + localStorage.setItem(STORAGE_KEY, id); + } +} + +/** + * Load the previously selected network from localStorage, defaulting to testnet. + */ +export function loadSelectedNetwork(): NetworkId { + if (typeof window !== "undefined") { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === "testnet" || stored === "mainnet") { + return stored; + } + } + return "testnet"; +} diff --git a/src/lib/sorosave.ts b/src/lib/sorosave.ts index ca84abb..4e49712 100644 --- a/src/lib/sorosave.ts +++ b/src/lib/sorosave.ts @@ -1,15 +1,27 @@ import { SoroSaveClient } from "@sorosave/sdk"; +import { getNetworkConfig, loadSelectedNetwork, type NetworkConfig } from "./network"; -const TESTNET_RPC_URL = - process.env.NEXT_PUBLIC_RPC_URL || "https://soroban-testnet.stellar.org"; -const NETWORK_PASSPHRASE = - process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015"; -const CONTRACT_ID = process.env.NEXT_PUBLIC_CONTRACT_ID || ""; +/** + * Create a SoroSaveClient configured for the given network. + * Falls back to env vars for backwards compatibility when no network is provided. + */ +export function createSoroSaveClient(network?: NetworkConfig): SoroSaveClient { + const config = network ?? getNetworkConfig(loadSelectedNetwork()); -export const sorosaveClient = new SoroSaveClient({ - contractId: CONTRACT_ID, - rpcUrl: TESTNET_RPC_URL, - networkPassphrase: NETWORK_PASSPHRASE, -}); + return new SoroSaveClient({ + contractId: config.contractId, + rpcUrl: config.rpcUrl, + networkPassphrase: config.networkPassphrase, + }); +} -export { TESTNET_RPC_URL, NETWORK_PASSPHRASE, CONTRACT_ID }; +// Default client — uses the persisted network selection. +// Components that are network-aware should prefer calling createSoroSaveClient() +// with the network from useNetwork() instead. +export const sorosaveClient = createSoroSaveClient(); + +// Re-export for backwards compatibility +const fallbackConfig = getNetworkConfig(loadSelectedNetwork()); +export const TESTNET_RPC_URL = fallbackConfig.rpcUrl; +export const NETWORK_PASSPHRASE = fallbackConfig.networkPassphrase; +export const CONTRACT_ID = fallbackConfig.contractId;