Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/markets/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" }) => (
Expand Down Expand Up @@ -80,7 +81,7 @@ const FilterBar = ({
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-secondary animate-pulse motion-reduce:animate-none"></span>
<span className="font-data-mono text-label-caps text-secondary uppercase">Stellar Testnet</span>
<span className="font-data-mono text-label-caps text-secondary uppercase">{NETWORK_LABEL}</span>
</div>
</div>
</div>
Expand Down
8 changes: 4 additions & 4 deletions app/markets/quick-view/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,10 +267,10 @@ function MarketQuickViewContent() {
return (
<main className="relative min-h-[calc(100vh-64px)] overflow-hidden bg-surface flex flex-col items-center justify-center p-6 text-center">
<span className="material-symbols-outlined text-error text-6xl mb-4">error</span>
<h2 className="text-headline-md mb-2">Market Not Found</h2>
<h2 className="text-headline-md mb-2">Market not found</h2>
<p className="text-on-surface-variant mb-6">The market you are looking for does not exist or could not be loaded.</p>
<Link href="/markets" className="bg-primary text-white px-6 py-2 rounded-lg font-label-caps">
BACK TO MARKETS
<Link href="/markets" className="bg-primary text-white px-6 py-2 rounded-lg font-label-caps uppercase">
Back to markets
</Link>
</main>
);
Expand Down Expand Up @@ -334,7 +334,7 @@ function MarketQuickViewContent() {
<Link
href="/markets"
aria-label="Close"
className="text-outline hover:text-on-surface transition-colors p-1"
className="text-outline hover:text-on-surface transition-colors min-h-11 min-w-11 flex items-center justify-center"
>
<span className="material-symbols-outlined">close</span>
</Link>
Expand Down
14 changes: 14 additions & 0 deletions app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Link from "next/link";

export default function NotFound() {
return (
<main className="relative min-h-[calc(100vh-64px)] bg-surface flex flex-col items-center justify-center p-6 text-center">
<span className="material-symbols-outlined text-outline text-6xl mb-4" aria-hidden="true">search_off</span>
<h1 className="text-headline-md mb-2">Page not found</h1>
<p className="text-on-surface-variant mb-6">The page you&apos;re looking for doesn&apos;t exist or has moved.</p>
<Link href="/markets" className="bg-primary text-white px-6 py-2 rounded-lg font-label-caps uppercase min-h-11 inline-flex items-center">
Back to markets
</Link>
</main>
);
}
24 changes: 24 additions & 0 deletions lib/hooks/useMediaQuery.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
29 changes: 29 additions & 0 deletions lib/markets/discovery-params.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
41 changes: 41 additions & 0 deletions lib/markets/discovery-params.ts
Original file line number Diff line number Diff line change
@@ -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<T extends string>(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<MarketCategory | "">(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();
}
3 changes: 2 additions & 1 deletion lib/stellar/WalletProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ export interface WalletContextValue {
disconnect: () => Promise<void>;
}

const WalletContext = createContext<WalletContextValue | null>(null);
/** Exported so tests and stories can supply a fake value without the wallet kit. */
export const WalletContext = createContext<WalletContextValue | null>(null);

export function useWallet(): WalletContextValue {
const ctx = useContext(WalletContext);
Expand Down
24 changes: 24 additions & 0 deletions lib/stellar/testing/fake-wallet.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
53 changes: 53 additions & 0 deletions lib/stellar/testing/fake-wallet.ts
Original file line number Diff line number Diff line change
@@ -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<FakeWalletPreset, WalletContextValue> = {
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> = {}): WalletContextValue {
return { ...PRESETS[preset], ...overrides };
}