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 |
- Features
- Tech stack
- Getting started
- Configuration
- Architecture
- Routing
- Authentication
- Server state with React Query
- Styling and UI conventions
- Error handling
- Deployment
- Adding a feature — a worked walkthrough
- Troubleshooting
- Contributing
- License
| 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. |
| 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.
- 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.
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 devThe dev server starts on http://localhost:5173.
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 devTwo 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.
| 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. |
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. |
Every backend blueprint is mounted under
/api/v1, whileapiClient.tspasses 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 preflightOPTIONShits an unmatched route./healthis 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.
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.
| 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.
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
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.
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 cacheduserblob written once at sign-in and never refreshed — so the header could render stale details indefinitely.authToken.tsstill removes those legacy keys (browsers running an older build may hold them) but nothing writes them any more.useCurrentUseris now the one source of truth for who is signed in.
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.
- Tailwind CSS v4, wired through the
@tailwindcss/viteplugin and imported fromsrc/index.css. There is notailwind.config.jsand no PostCSS config — v4 configures through CSS. - Mobile-first.
BottomNavon small screens,SideNavfrommdup; both read their entries fromcomponents/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
SpinnerandEmptycomponents rather than ad-hoc markup, so they stay consistent across screens. - Money and large numbers are formatted through
numbrohelpers infunctions/utils.ts— don't hand-rolltoFixedat a call site.
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.
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_URLin 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.
Say you're adding a "dividends" screen backed by GET /api/v1/dividends:
- Service — add
src/functions/dividendService.tswith one function that callsapiFetch("/dividends")and returns the unwrappeddata. - Key — add a
dividendsentry tosrc/queries/keys.ts, hierarchical if it will have detail views. - Hook — add
src/queries/dividends.tsexportinguseDividends(), plus the responseinterface. Mutations go here too, with theirinvalidateQueriescalls. - UI — build presentational pieces in
src/components/, usingSpinnerandEmptyfor the loading and empty states. - Page — add
src/pages/DividendsPage.tsxand register it inApp.tsxunderProtectedRoute/Layout. - Nav — if it's a top-level destination, add it to
components/navItems.tsxso it appears in bothSideNavandBottomNav. - Verify —
npm run lint && npm run build.
| 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. |
- Branch off
master. - Respect the layer boundaries: components use hooks from
src/queries/, hooks use services fromsrc/functions/, services useapiFetch. No barefetchin a component. - Add every new cache key to
src/queries/keys.ts, and invalidate it from the mutations that change it. - Keep TypeScript strict —
npm run buildtype-checks and will fail on errors. - Run
npm run lint && npm run buildbefore opening a PR. - 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.
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.