diff --git a/app/markets/page.tsx b/app/markets/page.tsx index 774f3bb..7ebc905 100644 --- a/app/markets/page.tsx +++ b/app/markets/page.tsx @@ -11,6 +11,7 @@ import { useOnChainMarket, useOnChainMarkets, type OnChainMarketView } from "@/l import type { Market, MarketStatus, MarketTracePreview } from "@/lib/api/markets"; import { BACKEND_CATEGORIES, BACKEND_CATEGORY_LABELS } from "@/lib/stellar/categories"; import { formatUsdc } from "@/lib/stellar/units"; +import { NETWORK_LABEL } from "@/lib/stellar/config"; /** Where a displayed value came from, wherever backend and chain can disagree. */ const SourceTag = ({ source }: { source: "chain" | "backend" }) => ( @@ -80,7 +81,7 @@ const FilterBar = ({
- Stellar Testnet + {NETWORK_LABEL}
diff --git a/app/markets/quick-view/page.tsx b/app/markets/quick-view/page.tsx index 63397eb..535cf76 100644 --- a/app/markets/quick-view/page.tsx +++ b/app/markets/quick-view/page.tsx @@ -267,10 +267,10 @@ function MarketQuickViewContent() { return (
error -

Market Not Found

+

Market not found

The market you are looking for does not exist or could not be loaded.

- - BACK TO MARKETS + + Back to markets
); @@ -334,7 +334,7 @@ function MarketQuickViewContent() { close diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..4c4578e --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,14 @@ +import Link from "next/link"; + +export default function NotFound() { + return ( +
+ +

Page not found

+

The page you're looking for doesn't exist or has moved.

+ + Back to markets + +
+ ); +} diff --git a/lib/hooks/useMediaQuery.ts b/lib/hooks/useMediaQuery.ts new file mode 100644 index 0000000..5cfc793 --- /dev/null +++ b/lib/hooks/useMediaQuery.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useCallback, useSyncExternalStore } from "react"; + +/** + * Tracks a CSS media query. Returns null on the server so prerendered HTML + * never depends on the viewport and hydration does not mismatch. + */ +export function useMediaQuery(query: string): boolean | null { + const subscribe = useCallback( + (onChange: () => void) => { + const mql = window.matchMedia(query); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + }, + [query], + ); + + return useSyncExternalStore( + subscribe, + () => window.matchMedia(query).matches, + () => null, + ); +} diff --git a/lib/markets/discovery-params.test.ts b/lib/markets/discovery-params.test.ts new file mode 100644 index 0000000..220c466 --- /dev/null +++ b/lib/markets/discovery-params.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULTS, parseDiscovery, serializeDiscovery, type DiscoveryState } from "./discovery-params"; + +const roundTrip = (s: DiscoveryState) => parseDiscovery(new URLSearchParams(serializeDiscovery(s))); + +describe("discovery params", () => { + it("omits defaults from the URL", () => { + expect(serializeDiscovery(DEFAULTS)).toBe(""); + }); + + it("round trips a full state in a stable key order", () => { + const state: DiscoveryState = { q: "fed cut", status: "RESOLVED", category: "FED", sort: "close-asc" }; + expect(serializeDiscovery(state)).toBe("q=fed+cut&status=RESOLVED&category=FED&sort=close-asc"); + expect(roundTrip(state)).toEqual(state); + }); + + it("keeps status ALL distinct from the default", () => { + expect(roundTrip({ ...DEFAULTS, status: "ALL" }).status).toBe("ALL"); + }); + + it("ignores unknown values", () => { + expect(parseDiscovery(new URLSearchParams("status=NOPE&category=WEATHER&sort=random"))).toEqual(DEFAULTS); + }); + + it("trims and limits the search query", () => { + expect(parseDiscovery(new URLSearchParams(`q=${"a".repeat(150)}`)).q).toHaveLength(100); + expect(parseDiscovery(new URLSearchParams("q=%20btc%20")).q).toBe("btc"); + }); +}); diff --git a/lib/markets/discovery-params.ts b/lib/markets/discovery-params.ts new file mode 100644 index 0000000..ad6a986 --- /dev/null +++ b/lib/markets/discovery-params.ts @@ -0,0 +1,41 @@ +import type { MarketCategory, MarketStatus } from "@/lib/api/markets"; +import { BACKEND_CATEGORIES } from "@/lib/stellar/categories"; + +export type SortKey = "newest" | "close-asc" | "close-desc" | "liquidity-desc" | "movement-desc"; + +export interface DiscoveryState { + q: string; + status: MarketStatus | "ALL"; + category: MarketCategory | ""; + sort: SortKey; +} + +export const DEFAULTS: DiscoveryState = { q: "", status: "ACTIVE", category: "", sort: "newest" }; + +const SORT_KEYS: SortKey[] = ["newest", "close-asc", "close-desc", "liquidity-desc", "movement-desc"]; +const STATUSES: DiscoveryState["status"][] = ["ALL", "PENDING", "ACTIVE", "RESOLVING", "RESOLVED", "CANCELLED"]; +const MAX_QUERY_LENGTH = 100; + +function oneOf(value: string | null, allowed: readonly T[], fallback: T): T { + return value !== null && (allowed as readonly string[]).includes(value) ? (value as T) : fallback; +} + +/** Reads discovery state from the URL. Unknown values fall back to the defaults. */ +export function parseDiscovery(sp: URLSearchParams): DiscoveryState { + return { + q: (sp.get("q") ?? "").trim().slice(0, MAX_QUERY_LENGTH), + status: oneOf(sp.get("status"), STATUSES, DEFAULTS.status), + category: oneOf(sp.get("category"), BACKEND_CATEGORIES, DEFAULTS.category), + sort: oneOf(sp.get("sort"), SORT_KEYS, DEFAULTS.sort), + }; +} + +/** Writes discovery state in a stable key order, leaving out defaults. */ +export function serializeDiscovery(state: DiscoveryState): string { + const sp = new URLSearchParams(); + if (state.q !== DEFAULTS.q) sp.set("q", state.q); + if (state.status !== DEFAULTS.status) sp.set("status", state.status); + if (state.category !== DEFAULTS.category) sp.set("category", state.category); + if (state.sort !== DEFAULTS.sort) sp.set("sort", state.sort); + return sp.toString(); +} diff --git a/lib/stellar/WalletProvider.tsx b/lib/stellar/WalletProvider.tsx index ce680ce..cdaea96 100644 --- a/lib/stellar/WalletProvider.tsx +++ b/lib/stellar/WalletProvider.tsx @@ -47,7 +47,8 @@ export interface WalletContextValue { disconnect: () => Promise; } -const WalletContext = createContext(null); +/** Exported so tests and stories can supply a fake value without the wallet kit. */ +export const WalletContext = createContext(null); export function useWallet(): WalletContextValue { const ctx = useContext(WalletContext); diff --git a/lib/stellar/testing/fake-wallet.test.ts b/lib/stellar/testing/fake-wallet.test.ts new file mode 100644 index 0000000..7673694 --- /dev/null +++ b/lib/stellar/testing/fake-wallet.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { declinedSigner, fakeWallet } from "./fake-wallet"; + +describe("fakeWallet", () => { + it("defaults to a disconnected wallet with no signer", () => { + const wallet = fakeWallet(); + expect(wallet.isConnected).toBe(false); + expect(wallet.signer).toBeNull(); + }); + + it("marks the wrong network preset as connected but on the wrong network", () => { + const wallet = fakeWallet("wrongNetwork"); + expect(wallet.isConnected).toBe(true); + expect(wallet.isWrongNetwork).toBe(true); + }); + + it("applies overrides on top of a preset", () => { + expect(fakeWallet("connected", { isAuthenticated: false }).isAuthenticated).toBe(false); + }); + + it("uses a signer that rejects as user declined", async () => { + await expect(declinedSigner.signTransaction("AAAA")).rejects.toEqual({ code: -4, message: "User declined" }); + }); +}); diff --git a/lib/stellar/testing/fake-wallet.ts b/lib/stellar/testing/fake-wallet.ts new file mode 100644 index 0000000..5cd957d --- /dev/null +++ b/lib/stellar/testing/fake-wallet.ts @@ -0,0 +1,53 @@ +import type { WalletContextValue } from "../WalletProvider"; +import type { Signer } from "../clients"; + +export type FakeWalletPreset = "disconnected" | "connecting" | "connected" | "wrongNetwork" | "unauthenticated" | "withError"; + +export const FAKE_ADDRESS = "GBFAKEWALLETADDRESSFORSTORIESANDTESTSONLYXXXXXXXXXXXXXXXX"; + +/** Rejects like a wallet whose user declined, so nothing built on a fake wallet can sign. */ +export const declinedSigner: Signer = { + publicKey: FAKE_ADDRESS, + signTransaction: async () => { + throw { code: -4, message: "User declined" }; + }, +}; + +const disconnected: WalletContextValue = { + address: null, + isConnected: false, + isConnecting: false, + walletNetworkPassphrase: null, + isWrongNetwork: false, + signer: null, + isAuthenticated: false, + error: null, + openModal: async () => false, + ensureSession: async () => false, + withSession: async (fn) => fn(), + disconnect: async () => {}, +}; + +const connected: WalletContextValue = { + ...disconnected, + address: FAKE_ADDRESS, + isConnected: true, + walletNetworkPassphrase: "Test SDF Network ; September 2015", + signer: declinedSigner, + isAuthenticated: true, + openModal: async () => true, + ensureSession: async () => true, +}; + +const PRESETS: Record = { + disconnected, + connecting: { ...disconnected, isConnecting: true }, + connected, + wrongNetwork: { ...connected, walletNetworkPassphrase: "Public Global Stellar Network ; September 2015", isWrongNetwork: true }, + unauthenticated: { ...connected, isAuthenticated: false, ensureSession: async () => false }, + withError: { ...disconnected, error: "Your wallet didn't respond. Try connecting again." }, +}; + +export function fakeWallet(preset: FakeWalletPreset = "disconnected", overrides: Partial = {}): WalletContextValue { + return { ...PRESETS[preset], ...overrides }; +}