Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
"@albedo-link/intent": "^0.12.0",
"@sorosave/sdk": "workspace:*",
"@stellar/freighter-api": "^2.0.0",
"next": "^14.2.0",
Expand Down
101 changes: 86 additions & 15 deletions src/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,47 @@
"use client";

import React, { createContext, useContext, useState, useCallback, useEffect } from "react";
import { connectWallet, getPublicKey, isFreighterInstalled } from "@/lib/wallet";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import {
WalletAdapter,
WalletId,
WalletNotInstalledError,
clearLastWalletId,
getAdapter,
loadLastWalletId,
saveLastWalletId,
} from "@/lib/wallets";
import { NETWORK_PASSPHRASE } from "@/lib/sorosave";

interface WalletContextType {
address: string | null;
isConnected: boolean;
isFreighterAvailable: boolean;
connect: () => Promise<void>;
activeWalletId: WalletId | null;
activeWallet: WalletAdapter | null;
connecting: boolean;
error: string | null;
connect: (walletId: WalletId) => Promise<void>;
disconnect: () => void;
signTransaction: (xdr: string) => Promise<string>;
}

const WalletContext = createContext<WalletContextType>({
address: null,
isConnected: false,
isFreighterAvailable: false,
activeWalletId: null,
activeWallet: null,
connecting: false,
error: null,
connect: async () => {},
disconnect: () => {},
signTransaction: async () => {
throw new Error("No wallet connected");
},
});

export function useWallet() {
Expand All @@ -25,33 +50,79 @@ export function useWallet() {

export function Providers({ children }: { children: React.ReactNode }) {
const [address, setAddress] = useState<string | null>(null);
const [isFreighterAvailable, setIsFreighterAvailable] = useState(false);
const [activeWalletId, setActiveWalletId] = useState<WalletId | null>(null);
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
isFreighterInstalled().then(setIsFreighterAvailable);
// Try to reconnect on load
getPublicKey().then((key) => {
if (key) setAddress(key);
});
const last = loadLastWalletId();
if (!last) return;
const adapter = getAdapter(last);
(async () => {
const available = await adapter.isAvailable();
if (!available) return;
const key = await adapter.getPublicKey();
if (key) {
setActiveWalletId(last);
setAddress(key);
}
})();
}, []);

const connect = useCallback(async () => {
const addr = await connectWallet();
if (addr) setAddress(addr);
const connect = useCallback(async (walletId: WalletId) => {
setConnecting(true);
setError(null);
try {
const adapter = getAdapter(walletId);
const key = await adapter.connect();
setActiveWalletId(walletId);
setAddress(key);
saveLastWalletId(walletId);
} catch (err) {
if (err instanceof WalletNotInstalledError) {
setError(`${walletId} is not installed. Open ${err.installUrl} to install it.`);
} else {
setError(err instanceof Error ? err.message : "Failed to connect wallet");
}
} finally {
setConnecting(false);
}
}, []);

const disconnect = useCallback(() => {
setAddress(null);
setActiveWalletId(null);
setError(null);
clearLastWalletId();
}, []);

const signTransaction = useCallback(
async (xdr: string) => {
if (!activeWalletId) {
throw new Error("No wallet connected");
}
const adapter = getAdapter(activeWalletId);
return adapter.signTransaction(xdr, {
networkPassphrase: NETWORK_PASSPHRASE,
});
},
[activeWalletId],
);

const activeWallet = activeWalletId ? getAdapter(activeWalletId) : null;

return (
<WalletContext.Provider
value={{
address,
isConnected: !!address,
isFreighterAvailable,
activeWalletId,
activeWallet,
connecting,
error,
connect,
disconnect,
signTransaction,
}}
>
{children}
Expand Down
38 changes: 17 additions & 21 deletions src/components/ConnectWallet.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,19 @@
"use client";

import { useState } from "react";
import { useWallet } from "@/app/providers";
import { shortenAddress } from "@sorosave/sdk";
import { WalletSelectModal } from "./WalletSelectModal";

export function ConnectWallet() {
const { address, isConnected, isFreighterAvailable, connect, disconnect } =
useWallet();

if (!isFreighterAvailable) {
return (
<a
href="https://www.freighter.app/"
target="_blank"
rel="noopener noreferrer"
className="bg-gray-200 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-300"
>
Install Freighter
</a>
);
}
const { address, isConnected, activeWallet, disconnect } = useWallet();
const [modalOpen, setModalOpen] = useState(false);

if (isConnected && address) {
return (
<div className="flex items-center space-x-3">
<span className="text-sm text-gray-600 bg-gray-100 px-3 py-1 rounded-full">
{activeWallet ? `${activeWallet.name} · ` : ""}
{shortenAddress(address)}
</span>
<button
Expand All @@ -37,11 +27,17 @@ export function ConnectWallet() {
}

return (
<button
onClick={connect}
className="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700 transition-colors"
>
Connect Wallet
</button>
<>
<button
onClick={() => setModalOpen(true)}
className="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary-700 transition-colors"
>
Connect Wallet
</button>
<WalletSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
/>
</>
);
}
10 changes: 3 additions & 7 deletions src/components/ContributeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@

import { useState } from "react";
import { useWallet } from "@/app/providers";
import { sorosaveClient, NETWORK_PASSPHRASE } from "@/lib/sorosave";
import { sorosaveClient } from "@/lib/sorosave";
import { formatAmount } from "@sorosave/sdk";
import { signTransaction } from "@/lib/wallet";

interface ContributeModalProps {
groupId: number;
Expand All @@ -19,7 +18,7 @@ export function ContributeModal({
isOpen,
onClose,
}: ContributeModalProps) {
const { address } = useWallet();
const { address, signTransaction } = useWallet();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

Expand All @@ -33,10 +32,7 @@ export function ContributeModal({

try {
const tx = await sorosaveClient.contribute(address, groupId, address);
const signedXdr = await signTransaction(
tx.toXDR(),
NETWORK_PASSPHRASE
);
const signedXdr = await signTransaction(tx.toXDR());

// TODO: Submit signed transaction
console.log("Signed contribution:", signedXdr);
Expand Down
10 changes: 3 additions & 7 deletions src/components/CreateGroupForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@

import { useState } from "react";
import { useWallet } from "@/app/providers";
import { sorosaveClient, NETWORK_PASSPHRASE } from "@/lib/sorosave";
import { sorosaveClient } from "@/lib/sorosave";
import { parseAmount } from "@sorosave/sdk";
import { signTransaction } from "@/lib/wallet";

export function CreateGroupForm() {
const { address, isConnected } = useWallet();
const { address, isConnected, signTransaction } = useWallet();
const [name, setName] = useState("");
const [tokenAddress, setTokenAddress] = useState("");
const [contributionAmount, setContributionAmount] = useState("");
Expand Down Expand Up @@ -36,10 +35,7 @@ export function CreateGroupForm() {
address
);

const signedXdr = await signTransaction(
tx.toXDR(),
NETWORK_PASSPHRASE
);
const signedXdr = await signTransaction(tx.toXDR());

// TODO: Submit signed transaction to network
console.log("Signed transaction:", signedXdr);
Expand Down
121 changes: 121 additions & 0 deletions src/components/WalletSelectModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"use client";

import { useEffect, useState } from "react";
import { useWallet } from "@/app/providers";
import { WalletAdapter, walletList } from "@/lib/wallets";

interface WalletSelectModalProps {
isOpen: boolean;
onClose: () => void;
}

export function WalletSelectModal({ isOpen, onClose }: WalletSelectModalProps) {
const { connect, connecting, error } = useWallet();
const [availability, setAvailability] = useState<Record<string, boolean>>({});

useEffect(() => {
if (!isOpen) return;
let cancelled = false;
Promise.all(
walletList.map(async (w) => [w.id, await w.isAvailable()] as const),
).then((entries) => {
if (cancelled) return;
setAvailability(Object.fromEntries(entries));
});
return () => {
cancelled = true;
};
}, [isOpen]);

if (!isOpen) return null;

const handleSelect = async (wallet: WalletAdapter) => {
if (availability[wallet.id] === false) return;
await connect(wallet.id);
onClose();
};

return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={onClose}
>
<div
className="bg-white rounded-xl p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">
Connect a wallet
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
aria-label="Close"
>
×
</button>
</div>

<div className="space-y-2">
{walletList.map((wallet) => {
const available = availability[wallet.id];
const checking = available === undefined;
return (
<div
key={wallet.id}
className="flex items-center justify-between border border-gray-200 rounded-lg p-3 hover:bg-gray-50"
>
<div className="flex items-center space-x-3">
<img
src={wallet.iconUrl}
alt=""
className="w-8 h-8 rounded"
onError={(e) => {
e.currentTarget.style.visibility = "hidden";
}}
/>
<div>
<div className="font-medium text-gray-900">
{wallet.name}
</div>
{checking && (
<div className="text-xs text-gray-400">Checking…</div>
)}
{!checking && !available && (
<div className="text-xs text-gray-500">Not installed</div>
)}
</div>
</div>
{available ? (
<button
onClick={() => handleSelect(wallet)}
disabled={connecting}
className="bg-primary-600 text-white px-3 py-1.5 rounded-md text-sm font-medium hover:bg-primary-700 disabled:opacity-50"
>
{connecting ? "Connecting…" : "Connect"}
</button>
) : (
<a
href={wallet.installUrl}
target="_blank"
rel="noopener noreferrer"
className="bg-gray-100 text-gray-700 px-3 py-1.5 rounded-md text-sm font-medium hover:bg-gray-200"
>
Install
</a>
)}
</div>
);
})}
</div>

{error && (
<div className="bg-red-50 text-red-700 p-3 rounded-lg text-sm mt-4">
{error}
</div>
)}
</div>
</div>
);
}
Loading