+
+
Open market {market.question}
+
+
+
{/* Header */}
@@ -75,6 +117,6 @@ export function MarketCard({ market, showCategory = true }: MarketCardProps) {
-
+
);
}
diff --git a/frontend/src/components/MobileBottomNav.tsx b/frontend/src/components/MobileBottomNav.tsx
index ef103f2..341dc55 100644
--- a/frontend/src/components/MobileBottomNav.tsx
+++ b/frontend/src/components/MobileBottomNav.tsx
@@ -55,6 +55,15 @@ export function MobileBottomNav() {
),
},
+ {
+ path: '/watchlist',
+ label: 'Watchlist',
+ icon: (
+
+ ),
+ },
{
path: '/multi-markets',
label: 'Multi',
diff --git a/frontend/src/components/__tests__/Footer.test.tsx b/frontend/src/components/__tests__/Footer.test.tsx
new file mode 100644
index 0000000..fc22d0d
--- /dev/null
+++ b/frontend/src/components/__tests__/Footer.test.tsx
@@ -0,0 +1,27 @@
+import { MemoryRouter } from 'react-router-dom';
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { Footer } from '../Footer';
+
+vi.mock('../../contexts/NetworkContext', () => ({
+ useNetwork: () => ({
+ network: 'testnet',
+ contractAddress: 'STTESTCONTRACT',
+ }),
+}));
+
+describe('Footer', () => {
+ it('links the contract to the active network explorer', () => {
+ render(
+
+
+
+ );
+
+ const contractLink = screen.getByRole('link', { name: 'Contract' });
+ expect(contractLink).toHaveAttribute(
+ 'href',
+ 'https://explorer.hiro.so/address/STTESTCONTRACT?chain=testnet'
+ );
+ });
+});
diff --git a/frontend/src/components/__tests__/Header.test.tsx b/frontend/src/components/__tests__/Header.test.tsx
new file mode 100644
index 0000000..52be44c
--- /dev/null
+++ b/frontend/src/components/__tests__/Header.test.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { Header } from '../Header';
+
+vi.mock('../WalletProvider', () => ({
+ useWallet: () => ({
+ isConnected: false,
+ address: null,
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ }),
+}));
+
+vi.mock('../../contexts/NetworkContext', () => ({
+ useNetwork: () => ({
+ isTestnet: false,
+ networkConfig: {
+ color: '#10B981',
+ label: 'Mainnet',
+ },
+ }),
+}));
+
+vi.mock('../NetworkSelector', () => ({
+ NetworkSelector: () => React.createElement('div', { 'data-testid': 'network-selector' }),
+}));
+
+vi.mock('../ThemeSwitcher', () => ({
+ ThemeSwitcher: () => React.createElement('div', { 'data-testid': 'theme-switcher' }),
+}));
+
+describe('Header', () => {
+ it('includes the watchlist navigation link', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByRole('link', { name: 'Watchlist' })).toHaveAttribute('href', '/watchlist');
+ });
+});
diff --git a/frontend/src/components/__tests__/MarketCard.test.tsx b/frontend/src/components/__tests__/MarketCard.test.tsx
new file mode 100644
index 0000000..cd3e6fe
--- /dev/null
+++ b/frontend/src/components/__tests__/MarketCard.test.tsx
@@ -0,0 +1,50 @@
+import { MemoryRouter } from 'react-router-dom';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, expect, it, beforeEach } from 'vitest';
+import { MarketCard } from '../MarketCard';
+import { WatchlistProvider } from '../../contexts/WatchlistContext';
+import { MarketStatus, MarketOutcome } from '../../types/market';
+import { loadWatchlistIds, saveWatchlistIds } from '../../utils/watchlist';
+
+const market = {
+ id: 7,
+ question: 'Will this market remain listed?',
+ creator: 'SPTEST',
+ endDate: 1000,
+ resolutionDate: 2000,
+ totalYesStake: 100,
+ totalNoStake: 50,
+ status: MarketStatus.ACTIVE,
+ outcome: MarketOutcome.NONE,
+ createdAt: 1,
+} as const;
+
+function renderCard() {
+ return render(
+
+
+
+
+
+ );
+}
+
+describe('MarketCard watchlist control', () => {
+ beforeEach(() => {
+ saveWatchlistIds([]);
+ });
+
+ it('toggles watchlist state without breaking the card link', async () => {
+ renderCard();
+
+ expect(screen.getByRole('link', { name: /open market/i })).toHaveAttribute('href', '/trade/7');
+
+ const button = screen.getByRole('button', { name: 'Add to watchlist' });
+ fireEvent.click(button);
+
+ expect(screen.getByRole('button', { name: 'Remove from watchlist' })).toBeTruthy();
+ await waitFor(() => {
+ expect(loadWatchlistIds()).toEqual([7]);
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/MobileBottomNav.test.tsx b/frontend/src/components/__tests__/MobileBottomNav.test.tsx
new file mode 100644
index 0000000..f7727ef
--- /dev/null
+++ b/frontend/src/components/__tests__/MobileBottomNav.test.tsx
@@ -0,0 +1,16 @@
+import { MemoryRouter } from 'react-router-dom';
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { MobileBottomNav } from '../MobileBottomNav';
+
+describe('MobileBottomNav', () => {
+ it('includes the watchlist route', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByRole('link', { name: 'Watchlist' })).toHaveAttribute('href', '/watchlist');
+ });
+});
diff --git a/frontend/src/contexts/WatchlistContext.tsx b/frontend/src/contexts/WatchlistContext.tsx
new file mode 100644
index 0000000..67cf70e
--- /dev/null
+++ b/frontend/src/contexts/WatchlistContext.tsx
@@ -0,0 +1,78 @@
+/* eslint-disable react-refresh/only-export-components */
+import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
+import {
+ addWatchlistId,
+ loadWatchlistIds,
+ removeWatchlistId,
+ saveWatchlistIds,
+ toggleWatchlistId,
+} from '../utils/watchlist';
+
+interface WatchlistContextValue {
+ marketIds: number[];
+ count: number;
+ isWatched: (marketId: number) => boolean;
+ addMarket: (marketId: number) => void;
+ removeMarket: (marketId: number) => void;
+ toggleMarket: (marketId: number) => void;
+ clearWatchlist: () => void;
+}
+
+const WatchlistContext = createContext
(null);
+
+interface WatchlistProviderProps {
+ children: ReactNode;
+}
+
+export function WatchlistProvider({ children }: WatchlistProviderProps) {
+ const [marketIds, setMarketIds] = useState(() => loadWatchlistIds());
+
+ useEffect(() => {
+ saveWatchlistIds(marketIds);
+ }, [marketIds]);
+
+ const isWatched = useCallback(
+ (marketId: number) => marketIds.includes(marketId),
+ [marketIds]
+ );
+
+ const addMarket = useCallback((marketId: number) => {
+ setMarketIds((current) => addWatchlistId(current, marketId));
+ }, []);
+
+ const removeMarket = useCallback((marketId: number) => {
+ setMarketIds((current) => removeWatchlistId(current, marketId));
+ }, []);
+
+ const toggleMarket = useCallback((marketId: number) => {
+ setMarketIds((current) => toggleWatchlistId(current, marketId));
+ }, []);
+
+ const clearWatchlist = useCallback(() => {
+ setMarketIds([]);
+ }, []);
+
+ const value = useMemo(() => ({
+ marketIds,
+ count: marketIds.length,
+ isWatched,
+ addMarket,
+ removeMarket,
+ toggleMarket,
+ clearWatchlist,
+ }), [marketIds, isWatched, addMarket, removeMarket, toggleMarket, clearWatchlist]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useWatchlist(): WatchlistContextValue {
+ const context = useContext(WatchlistContext);
+ if (!context) {
+ throw new Error('useWatchlist must be used within WatchlistProvider');
+ }
+ return context;
+}
diff --git a/frontend/src/contexts/__tests__/WatchlistContext.test.tsx b/frontend/src/contexts/__tests__/WatchlistContext.test.tsx
new file mode 100644
index 0000000..f56022a
--- /dev/null
+++ b/frontend/src/contexts/__tests__/WatchlistContext.test.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { describe, expect, it, beforeEach } from 'vitest';
+import { WatchlistProvider, useWatchlist } from '../WatchlistContext';
+import { loadWatchlistIds, saveWatchlistIds } from '../../utils/watchlist';
+
+function wrapper({ children }: { children: React.ReactNode }) {
+ return {children};
+}
+
+describe('WatchlistContext', () => {
+ beforeEach(() => {
+ saveWatchlistIds([]);
+ });
+
+ it('loads watchlist ids from storage and persists updates', async () => {
+ saveWatchlistIds([3, 1]);
+
+ const { result } = renderHook(() => useWatchlist(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.marketIds).toEqual([3, 1]);
+ });
+ expect(result.current.count).toBe(2);
+ expect(result.current.isWatched(3)).toBe(true);
+
+ act(() => {
+ result.current.toggleMarket(2);
+ });
+
+ expect(result.current.marketIds).toEqual([3, 1, 2]);
+ expect(loadWatchlistIds()).toEqual([3, 1, 2]);
+
+ act(() => {
+ result.current.removeMarket(1);
+ });
+
+ expect(result.current.marketIds).toEqual([3, 2]);
+ expect(loadWatchlistIds()).toEqual([3, 2]);
+
+ act(() => {
+ result.current.clearWatchlist();
+ });
+
+ expect(result.current.marketIds).toEqual([]);
+ expect(loadWatchlistIds()).toEqual([]);
+ });
+});
diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx
index 258d590..4918e2a 100644
--- a/frontend/src/pages/LandingPage.tsx
+++ b/frontend/src/pages/LandingPage.tsx
@@ -4,10 +4,13 @@ import { useMarkets } from '../hooks/useMarkets';
import { MarketCard } from '../components/MarketCard';
import { MarketStatus } from '../types/market';
import { formatStx } from '../utils/helpers';
+import { useNetwork } from '../contexts/NetworkContext';
+import { getExplorerAddressUrl } from '../utils/transactions';
export function LandingPage() {
const { connect, isConnected } = useWallet();
const { markets, isLoading } = useMarkets();
+ const { network, contractAddress } = useNetwork();
const featuredMarkets = markets
.filter((m) => m.status === MarketStatus.ACTIVE)
@@ -136,11 +139,11 @@ export function LandingPage() {
Get Started
)}
-
@@ -432,7 +433,7 @@ export function TradePage() {
Transaction submitted!
Your stake is being processed on the blockchain.
{
+ const marketById = new Map(markets.map((market) => [market.id, market]));
+ return marketIds
+ .map((marketId) => marketById.get(marketId))
+ .filter((market): market is Market => market !== undefined);
+ }, [markets, marketIds]);
+
+ const hasSavedMarkets = count > 0;
+
+ return (
+
+
+
+
+
+
Watchlist
+
Saved Markets
+
+ Keep track of the markets you care about and return to them quickly from one place.
+
+
+
+
+
+ {hasSavedMarkets && (
+
+ )}
+
+ Browse markets
+
+
+
+
+
+
+ {count} saved
+
+
+
+
+ {error ? (
+
+
Failed to load watchlist markets
+
{error}
+
+ ) : isLoading ? (
+
+ Loading saved markets...
+
+ ) : watchlistedMarkets.length > 0 ? (
+
+ {watchlistedMarkets.map((market) => (
+
+ ))}
+
+ ) : (
+
+
Your watchlist is empty
+
+ Tap the heart button on any market card to save it here.
+
+
+
+ Explore markets
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/__tests__/LandingPage.test.tsx b/frontend/src/pages/__tests__/LandingPage.test.tsx
new file mode 100644
index 0000000..b365da7
--- /dev/null
+++ b/frontend/src/pages/__tests__/LandingPage.test.tsx
@@ -0,0 +1,41 @@
+import { MemoryRouter } from 'react-router-dom';
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { LandingPage } from '../LandingPage';
+
+vi.mock('../../components/WalletProvider', () => ({
+ useWallet: () => ({
+ connect: vi.fn(),
+ isConnected: true,
+ }),
+}));
+
+vi.mock('../../hooks/useMarkets', () => ({
+ useMarkets: () => ({
+ markets: [],
+ isLoading: false,
+ }),
+}));
+
+vi.mock('../../contexts/NetworkContext', () => ({
+ useNetwork: () => ({
+ network: 'mainnet',
+ contractAddress: 'SPMAINNETCONTRACT',
+ }),
+}));
+
+describe('LandingPage', () => {
+ it('links to the contract on the active network explorer', () => {
+ render(
+
+
+
+ );
+
+ const contractLink = screen.getByRole('link', { name: 'View Contract' });
+ expect(contractLink).toHaveAttribute(
+ 'href',
+ 'https://explorer.hiro.so/address/SPMAINNETCONTRACT?chain=mainnet'
+ );
+ }, 20000);
+});
diff --git a/frontend/src/pages/__tests__/TradePage.test.tsx b/frontend/src/pages/__tests__/TradePage.test.tsx
index b46b587..4c0c0c9 100644
--- a/frontend/src/pages/__tests__/TradePage.test.tsx
+++ b/frontend/src/pages/__tests__/TradePage.test.tsx
@@ -9,6 +9,7 @@ const cvToJSONMock = vi.fn();
const parseMarketDataMock = vi.fn();
const networkContext = {
+ network: 'testnet',
stacksNetwork: { network: 'testnet' },
contractAddress: 'STTESTCONTRACT',
contractName: 'market-core',
@@ -87,7 +88,7 @@ describe('TradePage', () => {
});
fetchCallReadOnlyFunctionMock.mockResolvedValue({});
- render(
+ const { container } = render(
React.createElement(
MemoryRouter,
{ initialEntries: ['/trade/1'] },
@@ -109,5 +110,9 @@ describe('TradePage', () => {
contractName: networkContext.contractName,
functionName: 'get-market',
});
+
+ expect(
+ container.querySelector('a[href="https://explorer.hiro.so/address/SPTEST?chain=testnet"]')
+ ).toBeTruthy();
});
});
diff --git a/frontend/src/pages/__tests__/WatchlistPage.test.tsx b/frontend/src/pages/__tests__/WatchlistPage.test.tsx
new file mode 100644
index 0000000..6bb3aff
--- /dev/null
+++ b/frontend/src/pages/__tests__/WatchlistPage.test.tsx
@@ -0,0 +1,65 @@
+import { MemoryRouter } from 'react-router-dom';
+import { render, screen, waitFor } from '@testing-library/react';
+import { describe, expect, it, beforeEach, vi } from 'vitest';
+import { WatchlistPage } from '../WatchlistPage';
+import { WatchlistProvider } from '../../contexts/WatchlistContext';
+import { MarketStatus, MarketOutcome } from '../../types/market';
+import { saveWatchlistIds } from '../../utils/watchlist';
+
+vi.mock('../../hooks/useMarkets', () => ({
+ useMarkets: () => ({
+ markets: [
+ {
+ id: 4,
+ question: 'Will market four resolve yes?',
+ creator: 'SPTEST4',
+ endDate: 100,
+ resolutionDate: 200,
+ totalYesStake: 20,
+ totalNoStake: 10,
+ status: MarketStatus.ACTIVE,
+ outcome: MarketOutcome.NONE,
+ createdAt: 1,
+ },
+ {
+ id: 9,
+ question: 'Will market nine resolve no?',
+ creator: 'SPTEST9',
+ endDate: 100,
+ resolutionDate: 200,
+ totalYesStake: 15,
+ totalNoStake: 25,
+ status: MarketStatus.ACTIVE,
+ outcome: MarketOutcome.NONE,
+ createdAt: 2,
+ },
+ ],
+ isLoading: false,
+ error: null,
+ refetch: vi.fn(),
+ }),
+}));
+
+describe('WatchlistPage', () => {
+ beforeEach(() => {
+ saveWatchlistIds([]);
+ });
+
+ it('renders saved markets from the watchlist', async () => {
+ saveWatchlistIds([9, 4]);
+
+ render(
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Will market nine resolve no?')).toBeTruthy();
+ expect(screen.getByText('Will market four resolve yes?')).toBeTruthy();
+ expect(screen.getByRole('button', { name: 'Clear watchlist' })).toBeTruthy();
+ });
+ });
+});
diff --git a/frontend/src/utils/__tests__/transactions.test.ts b/frontend/src/utils/__tests__/transactions.test.ts
new file mode 100644
index 0000000..79ec22d
--- /dev/null
+++ b/frontend/src/utils/__tests__/transactions.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+import { getExplorerAddressUrl, getExplorerUrl } from '../transactions';
+import { NetworkType } from '../../types/network';
+
+describe('transactions explorer helpers', () => {
+ it('builds network-aware transaction explorer URLs', () => {
+ expect(getExplorerUrl('0xabc', NetworkType.MAINNET)).toBe(
+ 'https://explorer.hiro.so/txid/0xabc?chain=mainnet'
+ );
+ expect(getExplorerUrl('0xabc', NetworkType.TESTNET)).toBe(
+ 'https://explorer.hiro.so/txid/0xabc?chain=testnet'
+ );
+ });
+
+ it('builds network-aware address explorer URLs', () => {
+ expect(getExplorerAddressUrl('SPTEST', NetworkType.MAINNET)).toBe(
+ 'https://explorer.hiro.so/address/SPTEST?chain=mainnet'
+ );
+ expect(getExplorerAddressUrl('STTEST', NetworkType.TESTNET)).toBe(
+ 'https://explorer.hiro.so/address/STTEST?chain=testnet'
+ );
+ });
+});
diff --git a/frontend/src/utils/__tests__/watchlist.test.ts b/frontend/src/utils/__tests__/watchlist.test.ts
new file mode 100644
index 0000000..96f6574
--- /dev/null
+++ b/frontend/src/utils/__tests__/watchlist.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest';
+import {
+ addWatchlistId,
+ normalizeWatchlistIds,
+ removeWatchlistId,
+ toggleWatchlistId,
+} from '../watchlist';
+
+describe('watchlist utilities', () => {
+ it('normalizes watchlist ids into unique positive integers', () => {
+ expect(normalizeWatchlistIds([1, '2', 2, 0, -1, 'bad', 3.5, 3])).toEqual([1, 2, 3]);
+ });
+
+ it('adds, removes, and toggles ids predictably', () => {
+ expect(addWatchlistId([1], 2)).toEqual([1, 2]);
+ expect(removeWatchlistId([1, 2], 1)).toEqual([2]);
+ expect(toggleWatchlistId([1], 1)).toEqual([]);
+ expect(toggleWatchlistId([1], 2)).toEqual([1, 2]);
+ });
+});
diff --git a/frontend/src/utils/transactions.ts b/frontend/src/utils/transactions.ts
index 111c129..1d441dc 100644
--- a/frontend/src/utils/transactions.ts
+++ b/frontend/src/utils/transactions.ts
@@ -1,9 +1,12 @@
/**
* Transaction Status Types and Utilities
- *
+ *
* Provides types and functions for tracking transaction status on Stacks blockchain.
*/
+import { getExplorerUrls } from '../config/network';
+import type { NetworkType } from '../types/network';
+
export const TransactionStatus = {
PENDING: 'pending',
SUCCESS: 'success',
@@ -159,6 +162,13 @@ export function getStatusLabel(status: TransactionStatus): string {
/**
* Build explorer URL for transaction
*/
-export function getExplorerUrl(txId: string): string {
- return `https://explorer.hiro.so/txid/${txId}?chain=mainnet`;
+export function getExplorerUrl(txId: string, network?: NetworkType): string {
+ return getExplorerUrls(network).tx(txId);
+}
+
+/**
+ * Build explorer URL for an address
+ */
+export function getExplorerAddressUrl(address: string, network?: NetworkType): string {
+ return getExplorerUrls(network).address(address);
}
diff --git a/frontend/src/utils/watchlist.ts b/frontend/src/utils/watchlist.ts
new file mode 100644
index 0000000..120b88a
--- /dev/null
+++ b/frontend/src/utils/watchlist.ts
@@ -0,0 +1,78 @@
+export const WATCHLIST_STORAGE_KEY = '0xcast_watchlist';
+let watchlistMemoryIds: number[] = [];
+
+function getStorage(): Storage | undefined {
+ return typeof globalThis.localStorage !== 'undefined' ? globalThis.localStorage : undefined;
+}
+
+export function normalizeWatchlistIds(ids: unknown[]): number[] {
+ const normalized: number[] = [];
+
+ for (const id of ids) {
+ const numericId = typeof id === 'number' ? id : Number(id);
+ if (!Number.isInteger(numericId) || numericId <= 0) {
+ continue;
+ }
+ if (!normalized.includes(numericId)) {
+ normalized.push(numericId);
+ }
+ }
+
+ return normalized;
+}
+
+export function loadWatchlistIds(): number[] {
+ const storage = getStorage();
+ let persistedIds: number[] | null = null;
+
+ if (storage) {
+ try {
+ const stored = storage.getItem(WATCHLIST_STORAGE_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ persistedIds = Array.isArray(parsed) ? normalizeWatchlistIds(parsed) : [];
+ }
+ } catch (error) {
+ console.warn('Failed to load watchlist:', error);
+ }
+ }
+
+ if (persistedIds !== null) {
+ watchlistMemoryIds = persistedIds;
+ return persistedIds;
+ }
+
+ return [...watchlistMemoryIds];
+}
+
+export function saveWatchlistIds(ids: number[]): void {
+ const normalized = normalizeWatchlistIds(ids);
+ watchlistMemoryIds = [...normalized];
+
+ const storage = getStorage();
+ if (!storage) {
+ return;
+ }
+
+ try {
+ storage.setItem(WATCHLIST_STORAGE_KEY, JSON.stringify(normalized));
+ } catch (error) {
+ console.warn('Failed to save watchlist:', error);
+ }
+}
+
+export function addWatchlistId(ids: number[], marketId: number): number[] {
+ if (ids.includes(marketId)) {
+ return ids;
+ }
+
+ return [...ids, marketId];
+}
+
+export function removeWatchlistId(ids: number[], marketId: number): number[] {
+ return ids.filter((id) => id !== marketId);
+}
+
+export function toggleWatchlistId(ids: number[], marketId: number): number[] {
+ return ids.includes(marketId) ? removeWatchlistId(ids, marketId) : addWatchlistId(ids, marketId);
+}