Skip to content

Repository files navigation

iMockMarket — Frontend (trade-sim-app)

The web client for iMockMarket, a stock-trading simulator where players trade real market data with fake money and are ranked against their friends.

A React 19 + TypeScript single-page app built with Vite and Tailwind CSS v4. It is a pure client: it holds no business logic and no session token of its own — every rule (prices, fees, balances, rankings) is enforced by the API, and the session lives in an HttpOnly cookie the browser manages.

Frontend repo (this one) https://github.com/HakeemTheEmperor/trade-sim-app
Backend repo https://github.com/HakeemTheEmperor/trade-sim-backend
Live app https://app.imockmarket.toluwalase.me
Live API https://api-imockmarket.toluwalase.me/api/v1
API docs (Swagger UI) https://api-imockmarket.toluwalase.me/docs

Table of contents


Features

Area What the UI does
Onboarding Sign up, enter a 6-digit emailed OTP, sign in. Every new account starts with a $100,000 wallet.
Portfolio Cash, holdings, total equity, percentage change, and a paginated transaction ledger with per-transaction detail.
Market Browse, sort and search the tradable universe by symbol or company name; per-stock detail with company profile.
Charts Price-history charts and a side-by-side comparison view, via Chart.js.
Trading Buy and sell flows with fractional quantities, showing the spread cost before you confirm.
Watchlist Add and remove symbols, with watch state reflected on the stock view.
Leaderboard Season and career standings for the leagues you've joined; create a league or join one by code.
Shadows Invite, accept and manage the social links that let players follow each other.
Notifications Bell with unread count and a notification feed.
Account Profile editing, password change, and security settings.
Responsive shell Side navigation on desktop, bottom navigation on mobile.

Tech stack

Concern Choice Why
Framework React 19 —
Language TypeScript 5.7 —
Build tool Vite 6 Fast HMR; static output deployable anywhere.
Routing React Router 7 (BrowserRouter) Nested layout routes + a single guard route.
Server state TanStack Query 5 Caching, revalidation and invalidation — see below.
Styling Tailwind CSS v4 (via @tailwindcss/vite) Utility-first; no separate PostCSS config needed in v4.
Charts Chart.js + react-chartjs-2 Price history and comparison.
Icons react-icons —
Toasts sonner —
Number formatting numbro Currency and compact market-cap formatting.
Linting ESLint 9 (flat config) + typescript-eslint —
Hosting Vercel SPA rewrites configured in vercel.json.

There is no local state library (Redux/Zustand). Almost everything on screen is server state, and React Query owns it; the little that is genuinely local is component state.


Getting started

Prerequisites

  • Node.js 20+ and npm (Vite 6 requires Node 18+; 20 LTS or newer is recommended).
  • A running instance of trade-sim-backend, or network access to the deployed API.

Installation

git clone https://github.com/HakeemTheEmperor/trade-sim-app.git
cd trade-sim-app

npm install
cp .env.example .env      # then set VITE_API_BASE_URL — see Configuration
npm run dev

The dev server starts on http://localhost:5173.

Running against a local backend

The two repos are designed to run side by side:

# terminal 1 — API on :5000
cd trade-sim-backend
cp .env.example .env
docker compose up --build

# terminal 2 — UI on :5173
cd trade-sim-app
npm run dev

Two backend settings must line up for the browser to accept a session locally:

Backend variable Local value Why
CORS_ORIGINS must include http://localhost:5173 Cookie auth uses credentialed requests, so the origin has to be listed explicitly — * is not allowed.
JWT_COOKIE_SECURE False A Secure cookie is not stored over plain http://, so sign-in would appear to succeed and then immediately log you out.

With BREVO_API_KEY left unset on the backend, signup OTPs are written to the API's log (code=123456) instead of being emailed — so you can complete the verification flow locally with no email provider.

Available scripts

Command What it does
npm run dev Vite dev server with HMR on port 5173.
npm run build Type-checks the project (tsc -b) then builds to dist/. A type error fails the build.
npm run preview Serve the built dist/ locally — use this to sanity-check a production build.
npm run lint ESLint over the whole project.

Configuration

Vite only exposes variables prefixed with VITE_ to the bundle. Everything here ships to the browser — never put a secret in this file.

Variable Required Description
VITE_API_BASE_URL ✔ Base URL of the API, including the /api/v1 prefix, with no trailing slash. Local: http://localhost:5000/api/v1.

The /api/v1 prefix is not optional

Every backend blueprint is mounted under /api/v1, while apiClient.ts passes bare paths like /auth/signin. Omit the prefix and every endpoint 404s — and in a browser that surfaces as a CORS preflight failure ("does not have HTTP ok status"), not as a 404, because the preflight OPTIONS hits an unmatched route. /health is the only route without the prefix, so it keeps returning 200 and makes the base URL look fine. This is the single most common setup mistake in this project.

See .env.example for the annotated version. .env is git-ignored.


Architecture

Data flow

        Component  (src/components, src/pages)
             │  useQuery / useMutation
             ▼
        Query hook  (src/queries/*)          ← caching, invalidation, keys
             │  calls one function
             ▼
        Service     (src/functions/*)        ← endpoint paths, response unwrapping
             │
             ▼
        apiFetch    (src/functions/apiClient.ts)
             │  credentials: "include"  +  X-CSRF-TOKEN
             ▼
        trade-sim-backend  /api/v1/...

Every network call in the app goes through apiFetch. There are no bare fetch() calls in components, which is what makes credentials, CSRF headers and 401 handling a single-file concern rather than something each caller repeats.

The four layers

Layer Directory Responsibility Must not
Pages src/pages/ One per route. Compose components, read route params, own page-level layout. Call fetch directly.
Components src/components/ Presentational and interactive units. Know endpoint paths.
Query hooks src/queries/ useQuery/useMutation wrappers, cache keys, invalidation after mutations, exported response types. Build URLs.
Services src/functions/ One function per endpoint: path, method, body, and unwrapping { data }. Import React or hooks.

The one rule that keeps this honest: a component never imports from src/functions/ directly — it goes through a hook in src/queries/, so caching and invalidation can't be bypassed by accident.

Project structure

trade-sim-app/
├── index.html                  # Vite entry; fonts, favicons, <div id="root">
├── src/
│   ├── main.tsx                # createRoot + QueryClientProvider
│   ├── App.tsx                 # The full route table (see Routing)
│   ├── index.css / App.css     # Tailwind import + global styles
│   │
│   ├── pages/                  # One component per route
│   │   ├── Layout.tsx          # Authenticated shell: TopNav, SideNav, BottomNav, <Outlet/>
│   │   ├── ProtectedRoute.tsx  # Session guard — see Authentication
│   │   ├── Home.tsx            # /portfolio
│   │   ├── Market.tsx          # /market
│   │   ├── LeaderboardPage.tsx, Account.tsx, LearnPage.tsx
│   │   ├── LandingPage.tsx, LoginPage.tsx, SignUpPage.tsx, VerifyEmailPage.tsx
│   │   ├── NotFound.tsx
│   │   └── sub/                # Nested/detail routes (buy, sell, compare,
│   │                           #   stock view, transactions, settings, shadows)
│   │
│   ├── components/             # Reusable UI (~60 files)
│   │   ├── Nav: TopNav, SideNav, BottomNav, navItems, Breadcrumbs
│   │   ├── Market: StockList, StockItem, AllStocks, TopStocks, SearchBar
│   │   ├── Charts: PriceFluctuationChart, CompareChart, PercentageChange
│   │   ├── Trading: BuyStock, SellStock, QuantityInput
│   │   ├── Portfolio: Portfolio, Assets, AssetItem, UserStocks, Transactions
│   │   ├── Forms: TextInput, PasswordInput, OtpInput, MainButton, WideButton
│   │   └── State: Spinner, Empty, ComingSoon, ErrorBoundary
│   │
│   ├── queries/                # React Query hooks — the component-facing API
│   │   ├── client.ts           # QueryClient defaults (staleTime, retry policy)
│   │   ├── keys.ts             # EVERY cache key in the app, in one place
│   │   └── auth, stocks, transactions, watchlist, leaderboard,
│   │       notifications, profile, shadow, config
│   │
│   ├── functions/              # Thin API service layer
│   │   ├── apiClient.ts        # apiFetch — the single network choke point
│   │   ├── authToken.ts        # CSRF cookie read + session clearing
│   │   ├── utils.ts            # Formatting helpers
│   │   └── *Service.ts         # One module per backend resource
│   │
│   ├── mockData/               # Static fixtures used by placeholder screens
│   └── assets/
├── public/                     # Favicons, manifest, static images
├── vercel.json                 # SPA rewrite: all paths → /
├── vite.config.ts              # React + Tailwind plugins
├── eslint.config.js            # ESLint flat config
└── tsconfig*.json              # Project references: app + node

Routing

Defined in one place, src/App.tsx. Authenticated routes are nested inside ProtectedRoute → Layout, so the guard and the navigation shell are each declared once.

Path Access Screen
/ — Redirects to /portfolio.
/welcome public Landing page.
/signup, /login public Auth screens.
/verify-email public on purpose OTP entry. The user has no session until the code is accepted, so this cannot sit behind the guard.
/portfolio protected Dashboard.
/portfolio/userstocks protected All holdings.
/portfolio/transactions/all protected Transaction history.
/portfolio/transaction/:transactionId protected Transaction detail.
/market protected Market overview.
/market/stocks/all protected Full, sortable stock list.
/market/stock/:stockSymbol protected Stock detail + chart.
/market/buy/:stockSymbol protected Buy flow.
/market/sell/:stockSymbol protected Sell flow.
/market/compare/:stockSymbol protected Comparison chart.
/leaderboard protected Leagues and standings.
/learn protected Educational content.
/account protected Account hub.
/account/profile protected Profile editing.
/account/security protected Password and security.
/account/shadows protected Shadow links.
* — Not found.

Because this is a client-routed SPA, any host serving it must rewrite unknown paths to index.html; vercel.json does that for Vercel.


Authentication

The client never holds a token. The JWT lives in an HttpOnly cookie set by the API, which the browser attaches automatically to credentialed requests. JavaScript — including any XSS — cannot read it.

That shapes three things:

1. The server decides whether you're signed in. ProtectedRoute can't inspect a token, so it calls useCurrentUser() (GET /auth/me), renders a spinner while pending, and redirects to /welcome if the answer is null. Because it's a React Query hook, the nav bar and profile screen share the same in-flight request rather than each firing their own.

2. Mutations must echo a CSRF token. Cookie auth is CSRF-exposed by default, so the API also sets a readable csrf_access_token cookie that must come back as an X-CSRF-TOKEN header on POST/PUT/PATCH/DELETE. apiClient.ts attaches it centrally — individual callers never think about it.

3. A 401 means the session is gone. apiFetch clears cached session state and redirects to /welcome. Endpoints where a 401 is an expected answer rather than an expired session — signin, signup, and the /auth/me probe — opt out with { auth: false } and handle it themselves.

Historical note, useful when reading the code: the session used to live in localStorage, along with a cached user blob written once at sign-in and never refreshed — so the header could render stale details indefinitely. authToken.ts still removes those legacy keys (browsers running an older build may hold them) but nothing writes them any more. useCurrentUser is now the one source of truth for who is signed in.


Server state with React Query

Defaults live in src/queries/client.ts and are deliberate rather than inherited:

Setting Value Reasoning
refetchOnWindowFocus true Fixes the bug class this app had throughout: useEffect(…, []) fetched once on mount and never revalidated, so a tab left open showed whatever was true when it was opened.
staleTime 30_000 A compromise. Caching prices and balances for minutes shows stale money; refetching per remount hammers a free-tier backend.
retry (queries) 1 apiFetch already redirects on 401, and retrying a genuine 4xx just delays the error the user needs to see.
retry (mutations) 0 Never auto-retry: these buy and sell positions, and a retried POST is a duplicate trade.

Cache keys all live in src/queries/keys.ts. This is the file to read before writing any new query. Invalidation is the part of React Query that fails silently: a mutation invalidating ["portfolio"] while the query registered ["portfolio", 1] simply does nothing, and you get stale data with no error anywhere. Keys are therefore constructed centrally and hierarchically — invalidating stocks.all also matches stocks.detail(symbol), since React Query matches by prefix — so a mutation and its query cannot drift apart.

Anything needing fresher data than 30s (search, notification counts) overrides staleTime locally rather than lowering the global default.


Styling and UI conventions

  • Tailwind CSS v4, wired through the @tailwindcss/vite plugin and imported from src/index.css. There is no tailwind.config.js and no PostCSS config — v4 configures through CSS.
  • Mobile-first. BottomNav on small screens, SideNav from md up; both read their entries from components/navItems.tsx, so a new nav destination is added once.
  • Toasts are the standard way to report the outcome of an action. <Toaster/> is mounted inside the router so a toast raised immediately before a navigation (e.g. the unverified-account redirect out of sign-in) survives the route change.
  • Loading and empty states use the shared Spinner and Empty components rather than ad-hoc markup, so they stay consistent across screens.
  • Money and large numbers are formatted through numbro helpers in functions/utils.ts — don't hand-roll toFixed at a call site.

Error handling

RouteErrorBoundary wraps the whole route tree, and Layout carries its own boundary as well. That split is intentional: a crash inside a protected page is caught by the inner boundary and keeps the navigation shell usable, while the outer boundary covers the screens that render outside Layout (/login, /signup, /verify-email, /welcome) plus Layout and the nav bars themselves — without it, a render crash there is a white screen.

API errors surface as toasts; expired sessions are handled globally by apiFetch rather than per screen.


Deployment

Deployed as a static build on Vercel.

Setting Value
Build command npm run build
Output directory dist
Rewrites All paths → / (in vercel.json) — required for client-side routing.
Environment VITE_API_BASE_URL=https://api-imockmarket.toluwalase.me/api/v1

Env vars are baked in at build time. Changing VITE_API_BASE_URL in the Vercel dashboard has no effect until you redeploy.

The frontend and API must be same-site. Cookie auth requires both to share a registrable domain — hence app.imockmarket.toluwalase.me (Vercel) and api-imockmarket.toluwalase.me (Render), with the backend's JWT_COOKIE_DOMAIN set to .toluwalase.me. A *.vercel.app frontend calling a *.onrender.com API is cross-site and needs SameSite=None instead. The full domain setup, and the cookie-domain failure mode it avoids, is documented in the backend's docs/deployment.md.


Adding a feature — a worked walkthrough

Say you're adding a "dividends" screen backed by GET /api/v1/dividends:

  1. Service — add src/functions/dividendService.ts with one function that calls apiFetch("/dividends") and returns the unwrapped data.
  2. Key — add a dividends entry to src/queries/keys.ts, hierarchical if it will have detail views.
  3. Hook — add src/queries/dividends.ts exporting useDividends(), plus the response interface. Mutations go here too, with their invalidateQueries calls.
  4. UI — build presentational pieces in src/components/, using Spinner and Empty for the loading and empty states.
  5. Page — add src/pages/DividendsPage.tsx and register it in App.tsx under ProtectedRoute/Layout.
  6. Nav — if it's a top-level destination, add it to components/navItems.tsx so it appears in both SideNav and BottomNav.
  7. Verify — npm run lint && npm run build.

Troubleshooting

Symptom Likely cause
Every request fails a CORS preflight VITE_API_BASE_URL is missing the /api/v1 prefix, so the OPTIONS hits an unmatched route. Note that /health still works, which makes the URL look correct.
Browsing works, but any action logs you out The API's JWT_COOKIE_DOMAIN isn't set to the shared parent domain, so JS can't read the CSRF cookie and the API answers 401 Missing CSRF token.
Sign-in succeeds, then you're immediately signed out locally The backend has JWT_COOKIE_SECURE=True while you're on http://localhost — the browser refuses to store the cookie.
Blocked by CORS on localhost:5173 http://localhost:5173 isn't in the backend's CORS_ORIGINS.
Deployed app still calls the old API VITE_API_BASE_URL is compiled into the bundle; redeploy after changing it.
Empty market / no prices locally The backend's first seed takes ~15 minutes (provider rate limits). Watch the API logs.
A deep link 404s in production The host isn't rewriting unknown paths to index.html.

Contributing

  1. Branch off master.
  2. Respect the layer boundaries: components use hooks from src/queries/, hooks use services from src/functions/, services use apiFetch. No bare fetch in a component.
  3. Add every new cache key to src/queries/keys.ts, and invalidate it from the mutations that change it.
  4. Keep TypeScript strict — npm run build type-checks and will fail on errors.
  5. Run npm run lint && npm run build before opening a PR.
  6. If a change requires a backend change, land the API side first — see the backend repo.

Current gap: there is no automated test suite here. Changes are verified manually against a local backend. A Vitest + Testing Library setup, starting with the trading flows and apiClient's 401 handling, would be a high-value contribution.


License

No license file is currently committed to this repository, so default copyright applies. The companion backend is MIT-licensed; adding a matching LICENSE here is recommended before reuse.

Copyright (c) 2025 Akinyemi Toluwalase.

About

Frontend for the trade simulator app

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages