diff --git a/src/components/ExecutionPanel.tsx b/src/components/ExecutionPanel.tsx index 984890b..6cc7d0e 100644 --- a/src/components/ExecutionPanel.tsx +++ b/src/components/ExecutionPanel.tsx @@ -24,12 +24,12 @@ import { type FC, } from "react"; import { - connectWallet, getSOLBalance, getSPLTokenBalances, subscribeWallet, type WalletState, } from "../services/solflare"; +import WalletPickerModal from "./WalletPickerModal"; import { selectOptimalVault, type KaminoVault } from "../services/kamino"; import { usePriceMonitor } from "../hooks/usePriceMonitor"; import { useExecutionMachine } from "../hooks/useExecutionMachine"; @@ -271,15 +271,10 @@ export const ExecutionPanel: FC = () => { // the user rejected the connection popup, they'd see no feedback at // all. Capture the error and render it next to the Connect button. const [connectError, setConnectError] = useState(null); - const handleConnect = useCallback(async () => { + const [pickerOpen, setPickerOpen] = useState(false); + const handleConnect = useCallback(() => { setConnectError(null); - try { - await connectWallet(); - } catch (err) { - setConnectError( - err instanceof Error ? err.message : "Could not connect to Solflare.", - ); - } + setPickerOpen(true); }, []); const { tokens: rawTokens, loading: tokensLoading, error: tokensError } = useAvailableTokens(wallet); @@ -486,6 +481,10 @@ export const ExecutionPanel: FC = () => { // ------------------------------------------------------------------------ return (
+ setPickerOpen(false)} + />
Execute
{/* Multi-tab warning — surfaces only when ANOTHER tab in the @@ -603,11 +602,11 @@ export const ExecutionPanel: FC = () => { {connectError && (
= ({ : null; const [copiedAddr, setCopiedAddr] = useState(false); + const [pickerOpen, setPickerOpen] = useState(false); const handleCopyAddr = useCallback(() => { if (!wallet.address) return; void navigator.clipboard.writeText(wallet.address).then(() => { @@ -244,14 +245,14 @@ export const HeaderBar: FC = ({ {!wallet.connected ? ( ) : wallet.connected && profile && onEditProfile ? ( )}
+ setPickerOpen(false)} + /> ); }; diff --git a/src/components/WalletPickerModal.tsx b/src/components/WalletPickerModal.tsx new file mode 100644 index 0000000..267606c --- /dev/null +++ b/src/components/WalletPickerModal.tsx @@ -0,0 +1,338 @@ +/** + * LIMINAL — WalletPickerModal + * + * Connect Wallet butonu tıklandığında açılan seçici. 3 sağlayıcı: + * Solflare (default), Phantom, Backpack. Hepsi aynı browser + * extension API'sini paylaşır, sadece window scope'u farklı: + * `window.solflare`, `window.phantom.solana`, `window.backpack`. + * + * Davranış: + * - Mount'ta yüklü extension'ları detect eder, "Detected" rozeti + * ile vurgular. + * - Wallet'a tıklanınca selectWallet(id) sonra connectWallet() — + * hata olursa kart altında inline mesaj gösterir, kullanıcı + * başka wallet seçebilir. + * - Esc / backdrop click ile kapanır. + * + * Stil: LIMINAL pastel paletinde, "siyah cam" modal hissi. Aktif + * detected wallet pembe accent ile öne çıkar, install edilmemiş + * olanlar download linkine yönlendirir. + */ + +import { + useEffect, + useMemo, + useState, + type CSSProperties, + type FC, +} from "react"; +import { + connectWallet, + SUPPORTED_WALLETS, + type WalletId, +} from "../services/solflare"; + +const MONO = "var(--font-mono)"; +const SANS = "var(--font-sans)"; + +export type WalletPickerModalProps = { + open: boolean; + onClose: () => void; +}; + +export const WalletPickerModal: FC = ({ + open, + onClose, +}) => { + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + + // Detect installed extensions once per open. We re-read on open so + // a user who installs a wallet mid-session sees it without a refresh. + const installed = useMemo(() => { + if (!open) return new Set(); + return new Set( + SUPPORTED_WALLETS.filter((w) => w.detect()).map((w) => w.id), + ); + }, [open]); + + useEffect(() => { + if (!open) { + setBusy(null); + setError(null); + return; + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose]); + + const handlePick = async (id: WalletId): Promise => { + const info = SUPPORTED_WALLETS.find((w) => w.id === id); + if (!info) return; + if (!installed.has(id)) { + // Not installed → open the official download page in a new tab. + window.open(info.downloadUrl, "_blank", "noopener,noreferrer"); + return; + } + setError(null); + setBusy(id); + try { + await connectWallet(id); + onClose(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Unknown connection error"); + } finally { + setBusy(null); + } + }; + + if (!open) return null; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
e.stopPropagation()}> +
+
Connect
+

Choose your wallet

+

+ LIMINAL signs every transaction with simulation guards. + Solana wallets are interchangeable here. +

+
+ +
    + {SUPPORTED_WALLETS.map((w) => { + const isInstalled = installed.has(w.id); + const isBusy = busy === w.id; + return ( +
  • + +
  • + ); + })} +
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+
+
+ ); +}; + +const WalletGlyph: FC<{ id: WalletId }> = ({ id }) => { + const color = + id === "solflare" + ? "#f9b2d7" + : id === "phantom" + ? "#ab9ff2" + : "#e33e3f"; + const letter = id === "solflare" ? "S" : id === "phantom" ? "P" : "B"; + return ( + + ); +}; + +const styles: Record = { + scrim: { + position: "fixed", + inset: 0, + zIndex: 320, + background: "rgba(10, 10, 10, 0.55)", + backdropFilter: "blur(8px)", + WebkitBackdropFilter: "blur(8px)", + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: 20, + animation: "liminal-fade-in 200ms var(--ease-out, ease)", + }, + card: { + width: "min(440px, calc(100vw - 40px))", + maxHeight: "calc(100vh - 40px)", + overflowY: "auto", + background: + "linear-gradient(135deg, rgba(249, 178, 215, 0.10) 0%, rgba(207, 236, 243, 0.10) 50%, rgba(218, 249, 222, 0.10) 100%), var(--surface-raised)", + backdropFilter: "blur(20px) saturate(140%)", + WebkitBackdropFilter: "blur(20px) saturate(140%)", + border: "1px solid var(--color-stroke)", + borderRadius: 18, + boxShadow: + "0 30px 70px rgba(0, 0, 0, 0.22), 0 10px 24px rgba(249, 178, 215, 0.18)", + padding: 22, + display: "flex", + flexDirection: "column", + gap: 16, + animation: "liminal-flourish-pop 280ms cubic-bezier(0.34, 1.56, 0.64, 1) backwards", + }, + header: { display: "flex", flexDirection: "column", gap: 4 }, + eyebrow: { + fontFamily: MONO, + fontSize: 11, + letterSpacing: "0.14em", + color: "var(--color-text-muted)", + }, + title: { + margin: 0, + fontFamily: SANS, + fontWeight: 700, + fontSize: 22, + color: "var(--color-text)", + letterSpacing: "-0.01em", + }, + subtitle: { + margin: 0, + fontFamily: SANS, + fontSize: 13, + color: "var(--color-text-muted)", + lineHeight: 1.5, + }, + list: { + listStyle: "none", + padding: 0, + margin: 0, + display: "flex", + flexDirection: "column", + gap: 8, + }, + row: { + width: "100%", + display: "flex", + alignItems: "center", + gap: 12, + padding: "12px 14px", + borderRadius: 12, + border: "1px solid var(--color-stroke)", + background: "var(--surface-card)", + cursor: "pointer", + textAlign: "left", + fontFamily: SANS, + transition: + "background var(--motion-base) var(--ease-out), border-color var(--motion-base) var(--ease-out)", + }, + rowMain: { flex: 1, display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }, + rowLabel: { + fontFamily: SANS, + fontWeight: 600, + fontSize: 15, + color: "var(--color-text)", + }, + rowMeta: { + fontFamily: MONO, + fontSize: 11, + color: "var(--color-text-muted)", + }, + rowBadge: { + padding: "4px 10px", + borderRadius: 999, + border: "1px solid var(--color-stroke)", + fontFamily: MONO, + fontSize: 11, + fontWeight: 700, + flexShrink: 0, + }, + error: { + padding: "10px 12px", + borderRadius: 8, + border: "1px solid var(--color-danger)", + background: "rgba(220, 53, 69, 0.08)", + color: "var(--color-danger)", + fontFamily: MONO, + fontSize: 12, + }, + footer: { display: "flex", justifyContent: "flex-end" }, + dismissBtn: { + padding: "8px 16px", + border: "1px solid var(--color-stroke)", + background: "transparent", + color: "var(--color-text-muted)", + fontFamily: MONO, + fontSize: 12, + borderRadius: 8, + cursor: "pointer", + }, +}; + +export default WalletPickerModal; diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index 2f9f4d9..395b207 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -15,12 +15,12 @@ import { useMemo, useState, type CSSProperties, type FC } from "react"; import { - connectWallet, disconnectWallet, getWalletState, subscribeWallet, type WalletState, } from "../services/solflare"; +import WalletPickerModal from "../components/WalletPickerModal"; import { useEffect } from "react"; import { useWalletSummary } from "../hooks/useWalletSummary"; import { useProfile } from "../hooks/useProfile"; @@ -44,6 +44,7 @@ const WalletPage: FC = () => { const network = useMemo(() => getActiveNetworkConfig(), []); const [copied, setCopied] = useState(false); + const [pickerOpen, setPickerOpen] = useState(false); const handleCopy = (): void => { if (!wallet.address) return; @@ -121,27 +122,18 @@ const WalletPage: FC = () => {

- LIMINAL signs every transaction through Solflare with - simulation guards. Get the extension at{" "} - - solflare.com - - . + LIMINAL works with Solflare, Phantom, and Backpack. Every + transaction is signed with simulation guards.

)} @@ -153,6 +145,11 @@ const WalletPage: FC = () => {
+ + setPickerOpen(false)} + /> ); }; diff --git a/src/services/solflare.ts b/src/services/solflare.ts index f108943..6646dec 100644 --- a/src/services/solflare.ts +++ b/src/services/solflare.ts @@ -33,12 +33,17 @@ export type WalletState = { }; /** - * Solflare tarayıcı eklentisinin `window.solflare` üzerinden expose ettiği - * minimum arayüz. Transaction imzalama metodları bir sonraki bloklarda - * kullanılacak (Kamino deposit, DFlow swap, vb.). + * Tarayıcı eklentisinin `window.` üzerinden expose ettiği minimum + * arayüz. Solflare, Phantom ve Backpack üçü de aynı interface'i destekler + * (Phantom Solflare API'sını birebir takip etmiş, Backpack da uyumlu). + * + * Transaction imzalama metodları Kamino deposit / DFlow swap / durable- + * nonce pre-signing flow'ları için kullanılır. */ -interface SolflareProvider { +interface WalletProvider { isSolflare?: boolean; + isPhantom?: boolean; + isBackpack?: boolean; isConnected: boolean; publicKey: { toString(): string } | null; connect(opts?: { onlyIfTrusted?: boolean }): Promise; @@ -57,16 +62,70 @@ interface SolflareProvider { declare global { interface Window { - solflare?: SolflareProvider; + solflare?: WalletProvider; + phantom?: { solana?: WalletProvider }; + backpack?: WalletProvider; } } +export type WalletId = "solflare" | "phantom" | "backpack"; + +export type WalletInfo = { + id: WalletId; + label: string; + /** Browser download / install URL when extension isn't present. */ + downloadUrl: string; + /** Returns true if the extension is installed in this browser. */ + detect: () => boolean; +}; + +export const SUPPORTED_WALLETS: readonly WalletInfo[] = [ + { + id: "solflare", + label: "Solflare", + downloadUrl: "https://solflare.com/download", + detect: () => + typeof window !== "undefined" && !!window.solflare?.isSolflare, + }, + { + id: "phantom", + label: "Phantom", + downloadUrl: "https://phantom.app/download", + detect: () => + typeof window !== "undefined" && !!window.phantom?.solana?.isPhantom, + }, + { + id: "backpack", + label: "Backpack", + downloadUrl: "https://backpack.app/downloads", + detect: () => + typeof window !== "undefined" && !!window.backpack?.isBackpack, + }, +] as const; + +function resolveProvider(id: WalletId): WalletProvider | null { + if (typeof window === "undefined") return null; + if (id === "solflare") return window.solflare ?? null; + if (id === "phantom") return window.phantom?.solana ?? null; + if (id === "backpack") return window.backpack ?? null; + return null; +} + +function walletLabel(id: WalletId): string { + return SUPPORTED_WALLETS.find((w) => w.id === id)?.label ?? id; +} + +function walletDownloadUrl(id: WalletId): string { + return SUPPORTED_WALLETS.find((w) => w.id === id)?.downloadUrl ?? ""; +} + // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- const SESSION_KEY = "liminal:solflare:connected"; -const SOLFLARE_DOWNLOAD_URL = "https://solflare.com/download"; +const SELECTED_WALLET_KEY = "liminal:wallet:selected"; +const DEFAULT_WALLET: WalletId = "solflare"; type Listener = (state: WalletState) => void; @@ -78,6 +137,13 @@ class SolflareService { }; private listeners = new Set(); private initialized = false; + private selectedWallet: WalletId = DEFAULT_WALLET; + private boundProvider: WalletProvider | null = null; + private handlers: { + connect: (...args: unknown[]) => void; + disconnect: (...args: unknown[]) => void; + accountChanged: (...args: unknown[]) => void; + } | null = null; /** Reactive state subscription. Returns unsubscribe fn. */ subscribe(fn: Listener): () => void { @@ -97,11 +163,19 @@ class SolflareService { this.listeners.forEach((fn) => fn(this.state)); } - private getProvider(): SolflareProvider | null { - if (typeof window === "undefined") return null; - const provider = window.solflare; - if (!provider || !provider.isSolflare) return null; - return provider; + /** Returns the active wallet's window provider, or null if it's + * not installed. The "active" wallet is the one the user last + * picked via the picker (persisted) or the default Solflare. */ + private getProvider(): WalletProvider | null { + return resolveProvider(this.selectedWallet); + } + + getSelectedWalletId(): WalletId { + return this.selectedWallet; + } + + getSelectedWalletLabel(): string { + return walletLabel(this.selectedWallet); } private safeStorage(): Storage | null { @@ -113,6 +187,61 @@ class SolflareService { } } + private bindListeners(provider: WalletProvider): void { + // Tear down any previous binding so swapping wallets doesn't keep + // stale listeners firing. + if (this.boundProvider && this.handlers) { + try { + this.boundProvider.off?.("connect", this.handlers.connect); + this.boundProvider.off?.("disconnect", this.handlers.disconnect); + this.boundProvider.off?.( + "accountChanged", + this.handlers.accountChanged, + ); + } catch { + /* off() may not exist; ignore */ + } + } + const handlers = { + connect: () => { + const addr = provider.publicKey?.toString() ?? null; + this.setState({ + connected: !!addr, + connecting: false, + address: addr, + }); + if (addr) this.safeStorage()?.setItem(SESSION_KEY, "1"); + }, + disconnect: () => { + this.setState({ connected: false, connecting: false, address: null }); + this.safeStorage()?.removeItem(SESSION_KEY); + }, + accountChanged: () => { + const addr = provider.publicKey?.toString() ?? null; + this.setState({ connected: !!addr, address: addr }); + }, + }; + provider.on("connect", handlers.connect); + provider.on("disconnect", handlers.disconnect); + provider.on("accountChanged", handlers.accountChanged); + this.boundProvider = provider; + this.handlers = handlers; + } + + /** + * Pick which wallet the service should drive. Persisted so a refresh + * keeps the choice. Called by the wallet picker modal before connect. + */ + selectWallet(id: WalletId): void { + if (id === this.selectedWallet) return; + this.selectedWallet = id; + this.safeStorage()?.setItem(SELECTED_WALLET_KEY, id); + // Force re-bind on next init so listeners track the new provider. + this.initialized = false; + // Optimistic state reset — the new wallet hasn't connected yet. + this.setState({ connected: false, connecting: false, address: null }); + } + /** * Provider event listener'larını kurar ve session persistence için sessiz * reconnect dener. İdempotent — birden fazla çağrılabilir. @@ -121,28 +250,22 @@ class SolflareService { if (this.initialized) return; this.initialized = true; + // Read the persisted wallet choice on first init. After that the + // user only changes it through selectWallet(). + const stored = this.safeStorage()?.getItem(SELECTED_WALLET_KEY) as + | WalletId + | null; + if ( + stored && + SUPPORTED_WALLETS.some((w) => w.id === stored) + ) { + this.selectedWallet = stored; + } + const provider = this.getProvider(); if (!provider) return; - provider.on("connect", () => { - const addr = provider.publicKey?.toString() ?? null; - this.setState({ - connected: !!addr, - connecting: false, - address: addr, - }); - if (addr) this.safeStorage()?.setItem(SESSION_KEY, "1"); - }); - - provider.on("disconnect", () => { - this.setState({ connected: false, connecting: false, address: null }); - this.safeStorage()?.removeItem(SESSION_KEY); - }); - - provider.on("accountChanged", () => { - const addr = provider.publicKey?.toString() ?? null; - this.setState({ connected: !!addr, address: addr }); - }); + this.bindListeners(provider); // Session persistence: daha önce bağlanmışsa sessiz reconnect. if (this.safeStorage()?.getItem(SESSION_KEY) === "1") { @@ -158,16 +281,27 @@ class SolflareService { } } - /** Aktif kullanıcı etkileşimi ile Solflare bağlantısı. */ - async connectWallet(): Promise { + /** + * Active user-initiated wallet connection. Optional `id` argument + * lets the picker modal pass a different wallet before connecting + * (without forcing the caller to call selectWallet() separately). + */ + async connectWallet(id?: WalletId): Promise { + if (id && id !== this.selectedWallet) { + this.selectWallet(id); + } await this.init(); const provider = this.getProvider(); + const label = this.getSelectedWalletLabel(); if (!provider) { throw new Error( - `Solflare wallet not found. Please install the Solflare extension: ${SOLFLARE_DOWNLOAD_URL}`, + `${label} wallet not found. Please install the ${label} extension: ${walletDownloadUrl(this.selectedWallet)}`, ); } + if (!this.boundProvider) { + this.bindListeners(provider); + } try { this.setState({ connecting: true }); @@ -175,7 +309,7 @@ class SolflareService { const addr = provider.publicKey?.toString(); if (!addr) { throw new Error( - "Solflare connection failed. Please try again.", + `${label} connection failed. Please try again.`, ); } this.setState({ @@ -187,7 +321,7 @@ class SolflareService { return addr; } catch (err: unknown) { this.setState({ connecting: false }); - throw normalizeConnectError(err); + throw normalizeConnectError(err, label); } } @@ -198,24 +332,25 @@ class SolflareService { */ async signTransaction(tx: T): Promise { const provider = this.getProvider(); + const label = this.getSelectedWalletLabel(); if (!provider) { throw new Error( - "Solflare wallet not found. Please install the Solflare extension and connect.", + `${label} wallet not found. Please install the ${label} extension and connect.`, ); } if (!provider.isConnected || !this.state.connected) { throw new Error( - "Solflare not connected. Connect your wallet before signing a transaction.", + `${label} not connected. Connect your wallet before signing a transaction.`, ); } if (typeof provider.signTransaction !== "function") { throw new Error( - "Your Solflare version does not support transaction signing. Please update Solflare.", + `Your ${label} version does not support transaction signing. Please update ${label}.`, ); } - // Mobile: Solflare popup'ının/modal'ının tam açılmasını beklemek için - // 50ms kısa gecikme. Desktop'ta popup anında açılır, gecikme yok. + // Mobile: wallet popup'ının açılmasını beklemek için 50ms kısa + // gecikme. Desktop'ta popup anında açılır, gecikme yok. if (getIsMobileGlobal()) { await new Promise((resolve) => setTimeout(resolve, 50)); } @@ -223,7 +358,7 @@ class SolflareService { try { return await provider.signTransaction(tx); } catch (err: unknown) { - throw normalizeSignError(err); + throw normalizeSignError(err, label); } } @@ -238,14 +373,15 @@ class SolflareService { async signAllTransactions(txs: T[]): Promise { if (txs.length === 0) return []; const provider = this.getProvider(); + const label = this.getSelectedWalletLabel(); if (!provider) { throw new Error( - "Solflare wallet not found. Please install the Solflare extension and connect.", + `${label} wallet not found. Please install the ${label} extension and connect.`, ); } if (!provider.isConnected || !this.state.connected) { throw new Error( - "Solflare not connected. Connect your wallet before signing transactions.", + `${label} not connected. Connect your wallet before signing transactions.`, ); } @@ -257,11 +393,11 @@ class SolflareService { if (typeof provider.signAllTransactions === "function") { return await provider.signAllTransactions(txs); } - // Fallback — older Solflare builds only expose signTransaction. - // Sequential sign keeps the flow alive at the cost of N popups. + // Fallback — older builds only expose signTransaction. Sequential + // sign keeps the flow alive at the cost of N popups. if (typeof provider.signTransaction !== "function") { throw new Error( - "Your Solflare version does not support transaction signing. Please update Solflare.", + `Your ${label} version does not support transaction signing. Please update ${label}.`, ); } const signed: T[] = []; @@ -270,7 +406,7 @@ class SolflareService { } return signed; } catch (err: unknown) { - throw normalizeSignError(err); + throw normalizeSignError(err, label); } } @@ -290,7 +426,7 @@ class SolflareService { } } -function normalizeSignError(err: unknown): Error { +function normalizeSignError(err: unknown, label: string): Error { const message = err instanceof Error ? err.message @@ -299,13 +435,13 @@ function normalizeSignError(err: unknown): Error { : "unknown error"; if (/reject|cancel|denied|user rejected/i.test(message)) { return new Error( - "Transaction rejected in Solflare. Approve it to continue.", + `Transaction rejected in ${label}. Approve it to continue.`, ); } - return new Error(`Solflare signing error: ${message}`); + return new Error(`${label} signing error: ${message}`); } -function normalizeConnectError(err: unknown): Error { +function normalizeConnectError(err: unknown, label: string): Error { const message = err instanceof Error ? err.message @@ -317,13 +453,13 @@ function normalizeConnectError(err: unknown): Error { ? (err as { code?: number }).code : undefined; - // Solflare and EIP-1193 style rejection codes. + // EIP-1193 style rejection codes (Solflare/Phantom/Backpack all share). if (code === 4001 || /reject|cancel|denied|user rejected/i.test(message)) { return new Error( - 'Solflare connection rejected. Click "Approve" in the Solflare popup to connect your wallet.', + `${label} connection rejected. Click "Approve" in the ${label} popup to connect your wallet.`, ); } - return new Error(`Solflare connection error: ${message}`); + return new Error(`${label} connection error: ${message}`); } // --------------------------------------------------------------------------- @@ -336,8 +472,20 @@ export function initSolflare(): Promise { return solflareService.init(); } -export function connectWallet(): Promise { - return solflareService.connectWallet(); +export function connectWallet(id?: WalletId): Promise { + return solflareService.connectWallet(id); +} + +export function selectWallet(id: WalletId): void { + solflareService.selectWallet(id); +} + +export function getSelectedWalletId(): WalletId { + return solflareService.getSelectedWalletId(); +} + +export function getSelectedWalletLabel(): string { + return solflareService.getSelectedWalletLabel(); } export function disconnectWallet(): Promise {