diff --git a/apps/web/components/ui/skeleton.tsx b/apps/web/components/ui/skeleton.tsx
new file mode 100644
index 00000000..5b0a778c
--- /dev/null
+++ b/apps/web/components/ui/skeleton.tsx
@@ -0,0 +1,94 @@
+import { cn } from "@/lib/utils";
+
+interface SkeletonProps {
+ className?: string;
+}
+
+export function Skeleton({ className }: SkeletonProps) {
+ return (
+
+ );
+}
+
+export function RepoAvatarSkeleton({ className }: SkeletonProps) {
+ return
;
+}
+
+export function JobCardSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function JobDetailsSkeleton() {
+ return (
+
+
+
+
Loading job workspace
+
+ );
+}
diff --git a/apps/web/components/wallet/transaction-pending-notification.tsx b/apps/web/components/wallet/transaction-pending-notification.tsx
new file mode 100644
index 00000000..42a1d0c6
--- /dev/null
+++ b/apps/web/components/wallet/transaction-pending-notification.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { LoaderCircle, TriangleAlert, Unplug } from "lucide-react";
+import { useWalletSession } from "@/hooks/use-wallet-session";
+
+interface TransactionPendingNotificationProps {
+ isPending: boolean;
+ pendingText?: string;
+ txHash?: string | null;
+}
+
+function shortAddress(address: string): string {
+ return `${address.slice(0, 6)}...${address.slice(-6)}`;
+}
+
+export function TransactionPendingNotification({
+ isPending,
+ pendingText = "Transaction pending on Stellar. Keep this tab open while confirmation finalizes.",
+ txHash,
+}: TransactionPendingNotificationProps) {
+ const {
+ address,
+ appNetwork,
+ walletNetwork,
+ networkMismatch,
+ isConnecting,
+ isConnected,
+ error,
+ connect,
+ disconnect,
+ } = useWalletSession();
+
+ return (
+
+
+
+
+ Wallet Session
+
+
+ {isConnected && address
+ ? `Connected as ${shortAddress(address)}`
+ : "No wallet connected"}
+
+
App network: {appNetwork}
+ {walletNetwork ? (
+
Wallet network: {walletNetwork}
+ ) : null}
+
+
+
+ {isConnected ? (
+ void disconnect()}
+ aria-label="Disconnect Stellar wallet"
+ className="inline-flex items-center gap-1 rounded-xl border border-zinc-700 px-3 py-2 text-xs font-medium text-zinc-200 transition-opacity duration-200 hover:opacity-80"
+ >
+
+ Disconnect
+
+ ) : (
+ void connect()}
+ disabled={isConnecting}
+ aria-label="Connect Stellar wallet"
+ className="rounded-xl bg-indigo-500 px-3 py-2 text-xs font-semibold text-white transition-opacity duration-200 hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
+ >
+ {isConnecting ? "Connecting..." : "Connect Wallet"}
+
+ )}
+
+
+
+ {networkMismatch ? (
+
+
+
+ Network mismatch detected. Your wallet is connected to {walletNetwork},
+ but this app is configured for {appNetwork}. Switch wallet network before
+ signing.
+
+
+ ) : null}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {isPending ? (
+
+
+
+
{pendingText}
+ {txHash ?
tx: {txHash}
: null}
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/hooks/use-wallet-session.ts b/apps/web/hooks/use-wallet-session.ts
new file mode 100644
index 00000000..b143fca7
--- /dev/null
+++ b/apps/web/hooks/use-wallet-session.ts
@@ -0,0 +1,152 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ APP_STELLAR_NETWORK,
+ connectWallet,
+ disconnectWallet,
+ getConnectedWalletAddress,
+ getWalletNetwork,
+ type StellarNetwork,
+} from "@/lib/stellar";
+
+const SESSION_STORAGE_KEY = "lance.wallet.session.v1";
+
+interface WalletSessionCache {
+ address: string;
+ updatedAt: number;
+}
+
+function getStorage(): Storage | null {
+ if (typeof window === "undefined") return null;
+ return window.localStorage;
+}
+
+function readCachedSession(): WalletSessionCache | null {
+ const storage = getStorage();
+ if (!storage) return null;
+
+ try {
+ const value = storage.getItem(SESSION_STORAGE_KEY);
+ if (!value) return null;
+ const parsed = JSON.parse(value) as WalletSessionCache;
+ return parsed.address ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
+function persistSession(address: string | null): void {
+ const storage = getStorage();
+ if (!storage) return;
+
+ if (!address) {
+ storage.removeItem(SESSION_STORAGE_KEY);
+ return;
+ }
+
+ const payload: WalletSessionCache = { address, updatedAt: Date.now() };
+ storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(payload));
+}
+
+export function useWalletSession() {
+ const [address, setAddress] = useState
(null);
+ const [walletNetwork, setWalletNetwork] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isConnecting, setIsConnecting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const refreshWalletState = useCallback(async () => {
+ try {
+ const [connected, network] = await Promise.all([
+ getConnectedWalletAddress(),
+ getWalletNetwork(),
+ ]);
+ setAddress(connected);
+ setWalletNetwork(network);
+ persistSession(connected);
+ } catch (refreshError) {
+ setError(
+ refreshError instanceof Error
+ ? refreshError.message
+ : "Failed to restore wallet session.",
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ const cached = readCachedSession();
+ if (cached?.address) {
+ setAddress(cached.address);
+ }
+
+ void refreshWalletState();
+
+ const visibilityListener = () => {
+ if (!document.hidden) {
+ void refreshWalletState();
+ }
+ };
+
+ document.addEventListener("visibilitychange", visibilityListener);
+ return () => document.removeEventListener("visibilitychange", visibilityListener);
+ }, [refreshWalletState]);
+
+ const connect = useCallback(async () => {
+ setIsConnecting(true);
+ setError(null);
+
+ try {
+ const connectedAddress = await connectWallet();
+ const network = await getWalletNetwork();
+ setAddress(connectedAddress);
+ setWalletNetwork(network);
+ persistSession(connectedAddress);
+ return connectedAddress;
+ } catch (connectError) {
+ const message =
+ connectError instanceof Error
+ ? connectError.message
+ : "Wallet connection failed.";
+ setError(message);
+ return null;
+ } finally {
+ setIsConnecting(false);
+ }
+ }, []);
+
+ const disconnect = useCallback(async () => {
+ setError(null);
+
+ try {
+ await disconnectWallet();
+ } catch {
+ // disconnect should be best-effort so local session still clears.
+ }
+
+ setAddress(null);
+ setWalletNetwork(null);
+ persistSession(null);
+ }, []);
+
+ const networkMismatch = useMemo(
+ () => walletNetwork !== null && walletNetwork !== APP_STELLAR_NETWORK,
+ [walletNetwork],
+ );
+
+ return {
+ address,
+ walletNetwork,
+ appNetwork: APP_STELLAR_NETWORK,
+ isConnected: Boolean(address),
+ isLoading,
+ isConnecting,
+ networkMismatch,
+ error,
+ connect,
+ disconnect,
+ refreshWalletState,
+ };
+}
diff --git a/apps/web/lib/stellar.ts b/apps/web/lib/stellar.ts
index f476939d..50ec1e4f 100644
--- a/apps/web/lib/stellar.ts
+++ b/apps/web/lib/stellar.ts
@@ -1,24 +1,45 @@
import { StellarWalletsKit, Networks } from "@creit.tech/stellar-wallets-kit";
+import { StrKey, Transaction } from "@stellar/stellar-sdk";
-// TODO: See docs/ISSUES.md — "Wallet Connection"
let kit: StellarWalletsKit | null = null;
+export type StellarNetwork = Networks.TESTNET | Networks.PUBLIC;
+
+export const APP_STELLAR_NETWORK: StellarNetwork =
+ (process.env.NEXT_PUBLIC_STELLAR_NETWORK as StellarNetwork) ?? Networks.TESTNET;
+
+export function isValidStellarAddress(address: string): boolean {
+ return StrKey.isValidEd25519PublicKey(address);
+}
+
+export function assertValidStellarAddress(address: string): string {
+ if (!isValidStellarAddress(address)) {
+ throw new Error("Invalid Stellar account address returned by wallet.");
+ }
+ return address;
+}
+
+export function assertValidTransactionXdr(xdr: string): string {
+ try {
+ // Parse to ensure shape and network passphrase are valid for this app config.
+ new Transaction(xdr, APP_STELLAR_NETWORK);
+ return xdr;
+ } catch {
+ throw new Error("Invalid Stellar transaction XDR.");
+ }
+}
+
export function getWalletsKit(): StellarWalletsKit {
if (!kit) {
kit = new StellarWalletsKit({
- network:
- (process.env.NEXT_PUBLIC_STELLAR_NETWORK as Networks) ??
- Networks.TESTNET,
+ network: APP_STELLAR_NETWORK,
selectedWalletId: "freighter",
+ modules: ["freighter", "albedo", "xbull"],
});
}
return kit;
}
-/**
- * Opens the wallet-select modal and returns the connected public key.
- * Resolves once the user selects a wallet and the address is retrieved.
- */
export async function connectWallet(): Promise {
if (process.env.NEXT_PUBLIC_E2E === "true") return "GD...CLIENT";
const walletsKit = getWalletsKit();
@@ -28,36 +49,61 @@ export async function connectWallet(): Promise {
try {
walletsKit.closeModal();
const { address } = await walletsKit.getAddress();
- resolve(address);
+ resolve(assertValidStellarAddress(address));
} catch (err) {
reject(err);
}
},
+ onClosed: () => reject(new Error("Wallet connection cancelled by user.")),
});
});
}
+export async function disconnectWallet(): Promise {
+ if (process.env.NEXT_PUBLIC_E2E === "true") return;
+ await getWalletsKit().disconnect();
+}
+
export async function getConnectedWalletAddress(): Promise {
if (process.env.NEXT_PUBLIC_E2E === "true") return "GD...CLIENT";
try {
const { address } = await getWalletsKit().getAddress();
- return address ?? null;
+ return assertValidStellarAddress(address);
+ } catch {
+ return null;
+ }
+}
+
+export async function getWalletNetwork(): Promise {
+ const walletKit = getWalletsKit() as StellarWalletsKit & {
+ getNetwork?: () => Promise<{ network: string }>;
+ };
+
+ if (!walletKit.getNetwork) {
+ return null;
+ }
+
+ try {
+ const result = await walletKit.getNetwork();
+ const network = result.network;
+ if (network === Networks.TESTNET || network === Networks.PUBLIC) {
+ return network;
+ }
+ return null;
} catch {
return null;
}
}
-/**
- * Signs an XDR transaction string via the connected wallet.
- * Returns the signed XDR string ready for submission to the Soroban RPC.
- */
export async function signTransaction(xdr: string): Promise {
if (process.env.NEXT_PUBLIC_E2E === "true") return xdr;
+
const walletsKit = getWalletsKit();
- const networkPassphrase =
- (process.env.NEXT_PUBLIC_STELLAR_NETWORK as Networks) ?? Networks.TESTNET;
- const { signedTxXdr } = await walletsKit.signTransaction(xdr, {
- networkPassphrase,
+ const validatedXdr = assertValidTransactionXdr(xdr);
+
+ const { signedTxXdr } = await walletsKit.signTransaction(validatedXdr, {
+ networkPassphrase: APP_STELLAR_NETWORK,
});
- return signedTxXdr;
+
+ return assertValidTransactionXdr(signedTxXdr);
}
diff --git a/apps/web/types/stellar-wallets-kit.d.ts b/apps/web/types/stellar-wallets-kit.d.ts
index c340c978..3d9d2fad 100644
--- a/apps/web/types/stellar-wallets-kit.d.ts
+++ b/apps/web/types/stellar-wallets-kit.d.ts
@@ -1,5 +1,5 @@
// Ambient module declaration for @creit.tech/stellar-wallets-kit v2.
-// Required because v2's package.json is missing a `"types"` field.
+// Required because v2's package.json is missing a `types` field.
declare module "@creit.tech/stellar-wallets-kit" {
export enum Networks {
@@ -11,17 +11,25 @@ declare module "@creit.tech/stellar-wallets-kit" {
export interface StellarWalletsKitOptions {
network: Networks;
selectedWalletId?: string;
+ modules?: Array<"freighter" | "albedo" | "xbull">;
+ [key: string]: unknown;
+ }
+
+ export interface WalletModalOptions {
+ onWalletSelected?: () => void | Promise;
+ onClosed?: () => void;
[key: string]: unknown;
}
export class StellarWalletsKit {
constructor(options: StellarWalletsKitOptions);
- openModal(options?: Record): void;
+ openModal(options?: WalletModalOptions): void;
closeModal(): void;
getAddress(): Promise<{ address: string }>;
+ getNetwork?(): Promise<{ network: string }>;
signTransaction(
xdr: string,
- options?: Record,
+ options?: { networkPassphrase?: string; [key: string]: unknown },
): Promise<{ signedTxXdr: string }>;
disconnect(): Promise;
}