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 (
+
+ search_off
+
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 };
+}