diff --git a/src/components/GlobalSearch.tsx b/src/components/GlobalSearch.tsx new file mode 100644 index 0000000..ec21d7c --- /dev/null +++ b/src/components/GlobalSearch.tsx @@ -0,0 +1,362 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import type { Invoice } from "@stellar-split/sdk"; +import { truncateAddress } from "@stellar-split/sdk"; + +function useDebounce(value: T, ms: number) { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const t = window.setTimeout(() => setDebounced(value), ms); + return () => window.clearTimeout(t); + }, [value, ms]); + return debounced; +} + +function escapeRe(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function Highlight({ text, query }: { text: string; query: string }) { + const q = query.trim(); + if (!q) return <>{text}; + const re = new RegExp(`(${escapeRe(q)})`, "ig"); + const parts = text.split(re); + return ( + <> + {parts.map((part, i) => + part.toLowerCase() === q.toLowerCase() ? ( + + {part} + + ) : ( + {part} + ) + )} + + ); +} + +interface SearchResults { + invoices: Invoice[]; + addresses: { address: string; invoiceId: string }[]; +} + +function filterResults( + invoices: Invoice[], + query: string, + publicKey: string | null, + allPublic: boolean +): SearchResults { + const q = query.trim().toLowerCase(); + if (!q) return { invoices: [], addresses: [] }; + + const pool = allPublic || !publicKey + ? invoices + : invoices.filter( + (inv) => + inv.creator === publicKey || + inv.recipients.some((r) => r.address === publicKey) + ); + + const matchedInvoices = pool.filter( + (inv) => + inv.id.toLowerCase().includes(q) || + (inv as Invoice & { title?: string }).title?.toLowerCase().includes(q) || + inv.creator.toLowerCase().includes(q) || + inv.recipients.some((r) => r.address.toLowerCase().includes(q)) + ); + + const seenAddresses = new Set(); + const addresses: { address: string; invoiceId: string }[] = []; + for (const inv of pool) { + for (const r of inv.recipients) { + if (r.address.toLowerCase().includes(q) && !seenAddresses.has(r.address)) { + seenAddresses.add(r.address); + addresses.push({ address: r.address, invoiceId: inv.id }); + } + } + } + + return { invoices: matchedInvoices.slice(0, 5), addresses: addresses.slice(0, 5) }; +} + +interface Props { + invoices: Invoice[]; + publicKey: string | null; +} + +export default function GlobalSearch({ invoices, publicKey }: Props) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [allPublic, setAllPublic] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + const debounced = useDebounce(query, 300); + const results = filterResults(invoices, debounced, publicKey, allPublic); + const totalCount = results.invoices.length + results.addresses.length; + const hasQuery = debounced.trim().length > 0; + + // Flat list of navigable items for keyboard nav + const items: { type: "invoice" | "address"; id: string; href: string }[] = [ + ...results.invoices.map((inv) => ({ + type: "invoice" as const, + id: inv.id, + href: `/invoice/${inv.id}`, + })), + ...results.addresses.map((a) => ({ + type: "address" as const, + id: a.address, + href: `/creator/${a.address}`, + })), + ]; + + const openSearch = useCallback(() => { + setOpen(true); + setQuery(""); + setActiveIndex(0); + setTimeout(() => inputRef.current?.focus(), 50); + }, []); + + const closeSearch = useCallback(() => { + setOpen(false); + setQuery(""); + }, []); + + // Cmd/Ctrl+K shortcut + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + open ? closeSearch() : openSearch(); + } + if (e.key === "Escape" && open) closeSearch(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [open, openSearch, closeSearch]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, items.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter" && items[activeIndex]) { + router.push(items[activeIndex].href); + closeSearch(); + } + }; + + useEffect(() => { + setActiveIndex(0); + }, [debounced]); + + return ( + <> + {/* Trigger button in navbar */} + + + {/* Mobile icon-only trigger */} + + + {/* Modal overlay */} + {open && ( +
e.target === e.currentTarget && closeSearch()} + role="dialog" + aria-modal="true" + aria-label="Global search" + > +
+ {/* Input */} +
+ + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search invoices, IDs, addresses…" + className="flex-1 bg-transparent text-white placeholder:text-slate-500 text-sm outline-none" + aria-label="Search" + role="combobox" + aria-expanded={open} + aria-autocomplete="list" + aria-controls="search-results" + aria-activedescendant={items[activeIndex] ? `search-item-${activeIndex}` : undefined} + /> + + Esc + +
+ + {/* Toggle */} + {publicKey && ( +
+ + / + +
+ )} + + {/* Results */} +
    + {!hasQuery && ( +
  • + Start typing to search invoices and addresses… +
  • + )} + + {hasQuery && totalCount === 0 && ( +
  • + +

    No results for “{debounced}”

    + + Create new invoice β†’ + +
  • + )} + + {results.invoices.length > 0 && ( + <> +
  • + Invoices +
  • + {results.invoices.map((inv, idx) => { + const globalIdx = idx; + const isActive = activeIndex === globalIdx; + return ( +
  • + +
  • + ); + })} + + )} + + {results.addresses.length > 0 && ( + <> +
  • + Addresses +
  • + {results.addresses.map((a, idx) => { + const globalIdx = results.invoices.length + idx; + const isActive = activeIndex === globalIdx; + return ( +
  • + +
  • + ); + })} + + )} +
+ + {/* Footer hint */} +
+ ↑↓ navigate + ↡ open + Esc close +
+
+
+ )} + + ); +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index c9f7078..ca0aa92 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -8,6 +8,7 @@ import SimulationModeToggle from "@/components/SimulationModeToggle"; import NotificationCenter from "@/components/NotificationCenter"; import HeaderShortcutsButton from "@/components/HeaderShortcutsButton"; import NetworkStatus from "@/components/NetworkStatus"; +import GlobalSearch from "@/components/GlobalSearch"; const NAV_LINKS = [ { href: "/dashboard", label: "Dashboard" }, diff --git a/src/components/OnboardingFlow.tsx b/src/components/OnboardingFlow.tsx index 419f705..192fcaf 100644 --- a/src/components/OnboardingFlow.tsx +++ b/src/components/OnboardingFlow.tsx @@ -1,165 +1,150 @@ "use client"; import { useState, useEffect } from "react"; +import Link from "next/link"; import WalletConnect from "@/components/WalletConnect"; -const ONBOARDING_KEY = "stellarsplit_onboarded"; +const ONBOARDING_KEY = "split-onboarded"; const ONBOARDING_STEP_KEY = "stellarsplit_onboarding_step"; +const TOTAL_STEPS = 3; + +const STEPS = [ + { + icon: "πŸ”—", + title: "Connect your Freighter wallet", + description: + "Freighter is a browser extension wallet for Stellar. Connect it to create and pay invoices on-chain.", + }, + { + icon: "πŸ“„", + title: "Create your first invoice", + description: + "Fill in recipients, amounts, and a deadline. We'll pre-fill an example to get you started fast.", + }, + { + icon: "πŸ”—", + title: "Share and get paid", + description: + "Every invoice gets a unique link. Share it with payers β€” when the invoice is fully funded, USDC routes automatically to all recipients.", + }, +]; export default function OnboardingFlow() { const [show, setShow] = useState(false); const [step, setStep] = useState(1); useEffect(() => { - const onboarded = localStorage.getItem(ONBOARDING_KEY); - const savedStep = localStorage.getItem(ONBOARDING_STEP_KEY); - - if (onboarded === "true") { - setShow(false); - return; - } - - setShow(true); - if (savedStep) { - setStep(parseInt(savedStep, 10)); - } + try { + const onboarded = localStorage.getItem(ONBOARDING_KEY); + if (onboarded === "true") return; + const savedStep = localStorage.getItem(ONBOARDING_STEP_KEY); + setShow(true); + if (savedStep) setStep(Math.min(parseInt(savedStep, 10), TOTAL_STEPS)); + } catch {} }, []); const handleSkip = () => { - localStorage.setItem(ONBOARDING_KEY, "true"); - localStorage.removeItem(ONBOARDING_STEP_KEY); + try { + localStorage.setItem(ONBOARDING_KEY, "true"); + localStorage.removeItem(ONBOARDING_STEP_KEY); + } catch {} setShow(false); }; const handleNext = () => { - const nextStep = step < 4 ? step + 1 : 4; - if (step === 4) { + if (step >= TOTAL_STEPS) { handleSkip(); - } else { - setStep(nextStep); - localStorage.setItem(ONBOARDING_STEP_KEY, nextStep.toString()); + return; } + const next = step + 1; + setStep(next); + try { + localStorage.setItem(ONBOARDING_STEP_KEY, String(next)); + } catch {} }; if (!show) return null; + const current = STEPS[step - 1]; + return ( -
-
- {/* Progress indicator */} -
- {[1, 2, 3, 4].map((i) => ( +
+
+ {/* Header bar */} +
+ + Step {step} of {TOTAL_STEPS} + + +
+ + {/* Progress bar */} +
+ {Array.from({ length: TOTAL_STEPS }).map((_, i) => (
))}
- {/* Step 1: Connect Wallet */} - {step === 1 && ( -
-

Welcome to StellarSplit

-

- Let's get you started. First, connect your Freighter wallet. -

- -
- - -
+ {/* Content */} +
+
+ +

{current.title}

+

{current.description}

- )} - - {/* Step 2: What is StellarSplit */} - {step === 2 && ( -
-

What is StellarSplit?

-
-

- StellarSplit lets you create on-chain invoices where multiple payers each owe a share. -

-

- When the invoice is fully funded, USDC automatically routes to all recipients. -

-

- Perfect for splitting bills, group expenses, or shared projects. -

-
-
- - -
-
- )} - - {/* Step 3: Create First Invoice */} - {step === 3 && ( -
-

Create Your First Invoice

-

- Ready to create your first invoice? Click the button below to get started. -

- - Create Invoice - -
- - + + {/* Step 1: wallet connect inline */} + {step === 1 && ( +
+
-
- )} - - {/* Step 4: Done */} - {step === 4 && ( -
-

You're All Set!

-

- You can now create invoices and manage payments. Visit your dashboard anytime. -

- -
- )} + Create example invoice β†’ + + )} + + {/* Step 3: share explanation, no extra widget needed */} +
+ + {/* Footer actions */} +
+ + +
); diff --git a/src/components/WalletConnect.tsx b/src/components/WalletConnect.tsx index 1652093..a821dfa 100644 --- a/src/components/WalletConnect.tsx +++ b/src/components/WalletConnect.tsx @@ -1,21 +1,33 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { connectFreighter, getFreighterPublicKey, getWalletConnectPublicKey, connectWalletConnect, disconnectWalletConnect } from "@/lib/freighter"; import type { WalletType } from "@/lib/freighter"; import { truncateAddress, formatAmount } from "@stellar-split/sdk"; -import { fetchUsdcBalance, USDC_CONTRACT_ID } from "@/lib/stellar"; +import { fetchUsdcBalance } from "@/lib/stellar"; import QRModal from "@/components/QRModal"; +import WalletErrorModal, { type WalletErrorType } from "@/components/WalletErrorModal"; +import { useToast } from "@/contexts/ToastContext"; /** * WalletConnect β€” Connect via Freighter or WalletConnect * Displays truncated address when connected, supports both wallet types */ +function classifyWalletError(e: unknown): WalletErrorType { + const msg = (e instanceof Error ? e.message : String(e)).toLowerCase(); + if (msg.includes("not installed") || msg.includes("freighter is not") || msg.includes("no freighter")) return "not_installed"; + if (msg.includes("locked") || msg.includes("unlock")) return "locked"; + if (msg.includes("reject") || msg.includes("declin") || msg.includes("cancel") || msg.includes("denied")) return "rejected"; + if (msg.includes("network") || msg.includes("passphrase") || msg.includes("mismatch")) return "network_mismatch"; + return "not_installed"; +} + export default function WalletConnect() { + const { toast } = useToast(); const [address, setAddress] = useState(null); const [walletType, setWalletType] = useState(null); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [modalError, setModalError] = useState(null); const [balance, setBalance] = useState(null); const [balanceLoading, setBalanceLoading] = useState(false); @@ -84,14 +96,18 @@ export default function WalletConnect() { const handleConnect = async () => { setLoading(true); - setError(null); + setModalError(null); try { const pk = await connectFreighter(); setAddress(pk); setWalletType("freighter"); } catch (e) { - setError("Could not connect Freighter wallet."); - console.error(e); + const errType = classifyWalletError(e); + if (errType === "rejected") { + toast.error("Connection rejected. Try again when you're ready."); + } else { + setModalError(errType); + } } finally { setLoading(false); } @@ -99,7 +115,7 @@ export default function WalletConnect() { const handleConnectWalletConnect = async () => { setLoading(true); - setError(null); + setModalError(null); try { const { publicKey, uri } = await connectWalletConnect(); setAddress(publicKey); @@ -107,7 +123,7 @@ export default function WalletConnect() { setQrUri(uri); setQrOpen(true); } catch (e) { - setError("Could not initiate WalletConnect."); + toast.error("Could not initiate WalletConnect. Please try again."); console.error(e); } finally { setLoading(false); @@ -182,6 +198,12 @@ export default function WalletConnect() { onClose={() => setQrOpen(false)} onConnected={() => setQrOpen(false)} /> + + setModalError(null)} + onRetry={() => { setModalError(null); handleConnect(); }} + />
); } diff --git a/src/components/WalletErrorModal.tsx b/src/components/WalletErrorModal.tsx new file mode 100644 index 0000000..d2dadf2 --- /dev/null +++ b/src/components/WalletErrorModal.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import Link from "next/link"; + +export type WalletErrorType = + | "not_installed" + | "locked" + | "rejected" + | "network_mismatch" + | null; + +interface Props { + errorType: WalletErrorType; + onDismiss: () => void; + onRetry: () => void; + expectedNetwork?: string; +} + +const FREIGHTER_INSTALL_URL = "https://www.freighter.app/"; + +function NotInstalledContent({ onDismiss }: { onDismiss: () => void }) { + return ( + <> +
+ +

Freighter Not Installed

+

+ Freighter is a browser extension that acts as your Stellar wallet. + Install it to connect and start using StellarSplit. +

+
+ + {/* Mobile QR hint */} +

+ On mobile? Search Freighter in your browser's extension store. +

+ + ); +} + +function LockedContent({ onRetry, onDismiss }: { onRetry: () => void; onDismiss: () => void }) { + return ( + <> +
+ +

Freighter is Locked

+

+ Your Freighter wallet is locked. Unlock it first, then try connecting again. +

+
+
    +
  1. + 1 + Click the Freighter icon in your browser toolbar. +
  2. +
  3. + 2 + Enter your Freighter password to unlock. +
  4. +
  5. + 3 + Return here and click Try Again. +
  6. +
+
+ + +
+ + ); +} + +function NetworkMismatchContent({ + expectedNetwork, + onDismiss, +}: { + expectedNetwork: string; + onDismiss: () => void; +}) { + return ( + <> +
+ +

Wrong Network

+

+ StellarSplit is running on{" "} + {expectedNetwork}. Switch your + Freighter wallet to the same network to continue. +

+
+
    +
  1. + 1 + Open Freighter and go to Settings. +
  2. +
  3. + 2 + Select Network and choose{" "} + {expectedNetwork}. +
  4. +
  5. + 3 + Return here and reconnect your wallet. +
  6. +
+ + + ); +} + +export default function WalletErrorModal({ + errorType, + onDismiss, + onRetry, + expectedNetwork = "Testnet", +}: Props) { + const overlayRef = useRef(null); + const firstFocusRef = useRef(null); + + useEffect(() => { + if (!errorType) return; + // focus first interactive element + const el = overlayRef.current?.querySelector( + "button, a[href]" + ); + if (el) { + firstFocusRef.current = document.activeElement as HTMLElement; + el.focus(); + } + return () => { + firstFocusRef.current?.focus(); + }; + }, [errorType]); + + // Close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onDismiss(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [onDismiss]); + + if (!errorType) return null; + + return ( +
e.target === e.currentTarget && onDismiss()} + > +
+ + Wallet connection error + + + {errorType === "not_installed" && ( + + )} + {errorType === "locked" && ( + + )} + {errorType === "network_mismatch" && ( + + )} +
+
+ ); +}