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
66 changes: 35 additions & 31 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,35 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/providers";
import { Sidebar } from "@/components/ui/sidebar";
import { Toaster } from "@/components/ui/toaster";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
title: "CoopFinance — Cooperative Finance on Stellar",
description:
"Open-source platform for savings groups, cooperatives, and rotating-credit associations built on Stellar.",
keywords: ["cooperative", "savings", "Stellar", "USDC", "Africa", "SACCO", "Ajo", "Esusu"],
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.className} bg-gray-50 text-gray-900`}>
<Providers>
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 ml-64 p-6">{children}</main>
</div>
<Toaster />
</Providers>
</body>
</html>
);
}
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/providers";
import { Sidebar } from "@/components/ui/sidebar";
import { Header } from "@/components/ui/header";
import { Toaster } from "@/components/ui/toaster";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
title: "CoopFinance — Cooperative Finance on Stellar",
description:
"Open-source platform for savings groups, cooperatives, and rotating-credit associations built on Stellar.",
keywords: ["cooperative", "savings", "Stellar", "USDC", "Africa", "SACCO", "Ajo", "Esusu"],
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.className} bg-gray-50 text-gray-900`}>
<Providers>
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1 ml-64 flex flex-col min-h-screen">
<Header />
<main className="flex-1 p-6">{children}</main>
</div>
</div>
<Toaster />
</Providers>
</body>
</html>
);
}
41 changes: 21 additions & 20 deletions src/components/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";

export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() => new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, retry: 2 },
},
})
);

return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { WalletProvider } from "@/hooks/use-wallet";

export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() => new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, retry: 2 },
},
})
);

return (
<QueryClientProvider client={queryClient}>
<WalletProvider>{children}</WalletProvider>
</QueryClientProvider>
);
}
11 changes: 11 additions & 0 deletions src/components/ui/header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"use client";

import { WalletButton } from "@/components/ui/wallet-button";

export function Header() {
return (
<header className="sticky top-0 z-40 flex items-center justify-end h-14 px-6 bg-white border-b border-gray-200">
<WalletButton />
</header>
);
}
113 changes: 113 additions & 0 deletions src/components/ui/wallet-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"use client";

import { useState } from "react";
import Link from "next/link";
import { Copy, ExternalLink, Loader2, Wallet } from "lucide-react";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { clsx } from "clsx";
import { useWallet } from "@/hooks/use-wallet";

function shortenAddress(address: string): string {
if (address.length <= 10) return address;
return `${address.slice(0, 4)}...${address.slice(-4)}`;
}

export function WalletButton() {
const { address, isConnecting, connect, disconnect } = useWallet();
const [copied, setCopied] = useState(false);

if (isConnecting) {
return (
<button
type="button"
disabled
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 bg-gray-100 rounded-lg"
>
<Loader2 className="w-4 h-4 animate-spin" />
Connecting...
</button>
);
}

if (!address) {
return (
<button
type="button"
onClick={() => void connect()}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors"
>
<Wallet className="w-4 h-4" />
Connect Wallet
</button>
);
}

const explorerUrl = `https://stellar.expert/explorer/testnet/account/${address}`;

const copyAddress = async () => {
await navigator.clipboard.writeText(address);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
};

return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<span className="w-2 h-2 rounded-full bg-green-500" />
{shortenAddress(address)}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="end"
sideOffset={8}
className="z-50 min-w-[240px] rounded-lg border border-gray-200 bg-white p-2 shadow-lg"
>
<div className="px-2 py-1.5">
<p className="text-xs text-gray-400 mb-1">Connected address</p>
<div className="flex items-start gap-2">
<p className="text-xs font-mono text-gray-800 break-all flex-1">{address}</p>
<button
type="button"
onClick={() => void copyAddress()}
className="shrink-0 p-1 text-gray-400 hover:text-gray-700 rounded"
aria-label="Copy address"
>
<Copy className="w-4 h-4" />
</button>
</div>
{copied && <p className="text-xs text-green-600 mt-1">Copied!</p>}
</div>
<DropdownMenu.Separator className="my-1 h-px bg-gray-100" />
<DropdownMenu.Item asChild>
<Link
href={explorerUrl}
target="_blank"
rel="noopener noreferrer"
className={clsx(
"flex items-center gap-2 px-2 py-1.5 text-sm text-gray-700 rounded-md",
"outline-none cursor-pointer hover:bg-gray-50",
)}
>
<ExternalLink className="w-4 h-4" />
View on Stellar Explorer
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => disconnect()}
className={clsx(
"flex items-center px-2 py-1.5 text-sm text-red-600 rounded-md",
"outline-none cursor-pointer hover:bg-red-50",
)}
>
Disconnect
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
144 changes: 89 additions & 55 deletions src/hooks/use-wallet.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,89 @@
"use client";

import { useState, useCallback } from "react";
import {
StellarWalletsKit,
WalletNetwork,
FREIGHTER_ID,
LOBSTR_ID,
xBullWalletId,
} from "@creit.tech/stellar-wallets-kit";

const kit = new StellarWalletsKit({
network: WalletNetwork.TESTNET,
selectedWalletId: FREIGHTER_ID,
wallets: [FREIGHTER_ID, LOBSTR_ID, xBullWalletId],
});

export function useWallet() {
const [address, setAddress] = useState<string | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);

const connect = useCallback(async () => {
setIsConnecting(true);
setError(null);
try {
await kit.openModal({
onWalletSelected: async (option) => {
kit.setWallet(option.id);
const { address: addr } = await kit.getAddress();
setAddress(addr);
},
});
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to connect wallet");
} finally {
setIsConnecting(false);
}
}, []);

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

const signTransaction = useCallback(async (xdr: string) => {
if (!address) throw new Error("Wallet not connected");
const { signedTxXdr } = await kit.signTransaction(xdr, {
address,
networkPassphrase: WalletNetwork.TESTNET,
});
return signedTxXdr;
}, [address]);

return { address, isConnecting, error, connect, disconnect, signTransaction };
}
"use client";

import {
createContext,
useCallback,
useContext,
useState,
type ReactNode,
} from "react";
import {
StellarWalletsKit,
WalletNetwork,
FREIGHTER_ID,
LOBSTR_ID,
xBullWalletId,
} from "@creit.tech/stellar-wallets-kit";

const kit = new StellarWalletsKit({
network: WalletNetwork.TESTNET,
selectedWalletId: FREIGHTER_ID,
wallets: [FREIGHTER_ID, LOBSTR_ID, xBullWalletId],
});

type WalletContextValue = {
address: string | null;
isConnecting: boolean;
error: string | null;
connect: () => Promise<void>;
disconnect: () => void;
signTransaction: (xdr: string) => Promise<string>;
};

const WalletContext = createContext<WalletContextValue | null>(null);

export function WalletProvider({ children }: { children: ReactNode }) {
const [address, setAddress] = useState<string | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);

const connect = useCallback(async () => {
setIsConnecting(true);
setError(null);
try {
await kit.openModal({
onWalletSelected: async (option) => {
kit.setWallet(option.id);
const { address: addr } = await kit.getAddress();
setAddress(addr);
},
});
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to connect wallet");
} finally {
setIsConnecting(false);
}
}, []);

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

const signTransaction = useCallback(
async (xdr: string) => {
if (!address) throw new Error("Wallet not connected");
const { signedTxXdr } = await kit.signTransaction(xdr, {
address,
networkPassphrase: WalletNetwork.TESTNET,
});
return signedTxXdr;
},
[address],
);

return (
<WalletContext.Provider
value={{ address, isConnecting, error, connect, disconnect, signTransaction }}
>
{children}
</WalletContext.Provider>
);
}

export function useWallet(): WalletContextValue {
const ctx = useContext(WalletContext);
if (!ctx) {
throw new Error("useWallet must be used within WalletProvider");
}
return ctx;
}