diff --git a/frontend/README.md b/frontend/README.md
index 7dbf7eb..26174d6 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -1,73 +1,22 @@
-# React + TypeScript + Vite
+# Frontend
-This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+React + TypeScript client for the exchange WebSocket API.
-Currently, two official plugins are available:
+## Dev server
-- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
-- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
-
-## React Compiler
-
-The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
-
-## Expanding the ESLint configuration
-
-If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
-
-```js
-export default defineConfig([
- globalIgnores(['dist']),
- {
- files: ['**/*.{ts,tsx}'],
- extends: [
- // Other configs...
-
- // Remove tseslint.configs.recommended and replace with this
- tseslint.configs.recommendedTypeChecked,
- // Alternatively, use this for stricter rules
- tseslint.configs.strictTypeChecked,
- // Optionally, add this for stylistic rules
- tseslint.configs.stylisticTypeChecked,
-
- // Other configs...
- ],
- languageOptions: {
- parserOptions: {
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
- tsconfigRootDir: import.meta.dirname,
- },
- // other options...
- },
- },
-])
+```bash
+npm install
+npm run dev
```
-You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
+Open the URL Vite prints (usually `http://localhost:5173`).
-```js
-// eslint.config.js
-import reactX from 'eslint-plugin-react-x'
-import reactDom from 'eslint-plugin-react-dom'
+## Connecting to the backend
-export default defineConfig([
- globalIgnores(['dist']),
- {
- files: ['**/*.{ts,tsx}'],
- extends: [
- // Other configs...
- // Enable lint rules for React
- reactX.configs['recommended-typescript'],
- // Enable lint rules for React DOM
- reactDom.configs.recommended,
- ],
- languageOptions: {
- parserOptions: {
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
- tsconfigRootDir: import.meta.dirname,
- },
- // other options...
- },
- },
-])
-```
+1. Start the C++ backend on port `7000` with SSL certs (see `backend/README.md`).
+2. In the UI, connect to `wss://localhost:7000`.
+3. Log in with a demo user (`shashwat` / `shashwat` or `nero` / `nero`).
+4. Subscribe to `market/snapshot` to stream live bid/ask levels.
+
+Browsers reject self-signed certificates by default. Trust `backend/certs/cert.pem`
+locally, or accept the certificate warning before the WebSocket handshake succeeds.
diff --git a/frontend/index.html b/frontend/index.html
index 0fca6f0..48c3de0 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
-
frontend
+ Exchange
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 339363b..7b2c9bf 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,217 @@
-function App() {
- return Hello world! ;
+import { useState, type FormEvent } from 'react';
+import { useExchangeSocket } from './hooks/useExchangeSocket';
+import { DEFAULT_WS_URL, type BookLevels } from './types';
+
+const LEVELS = [
+ 'level_1',
+ 'level_2',
+ 'level_3',
+ 'level_4',
+ 'level_5',
+] as const;
+
+function formatPrice(n: number): string {
+ return n.toFixed(6);
}
-export default App;
+function BookSide({
+ title,
+ levels,
+ side,
+}: {
+ title: string;
+ levels: BookLevels | undefined;
+ side: 'ask' | 'bid';
+}) {
+ return (
+
+
{title}
+
+
+
+ Level
+ Price
+
+
+
+ {LEVELS.map((key, i) => (
+
+ {i + 1}
+
+ {levels ? formatPrice(levels[key]) : '—'}
+
+
+ ))}
+
+
+
+ );
+}
+
+function statusLabel(status: string): string {
+ return status.replaceAll('_', ' ');
+}
+
+export default function App() {
+ const [wsUrl, setWsUrl] = useState(DEFAULT_WS_URL);
+ const [user, setUser] = useState('shashwat');
+ const [password, setPassword] = useState('shashwat');
+
+ const {
+ connectionStatus,
+ authStatus,
+ username,
+ snapshot,
+ subscribed,
+ lastError,
+ log,
+ connect,
+ disconnect,
+ login,
+ subscribe,
+ unsubscribe,
+ } = useExchangeSocket(wsUrl);
+
+ const onLogin = (e: FormEvent) => {
+ e.preventDefault();
+ login(user, password);
+ };
+
+ const connected = connectionStatus === 'connected';
+ const authenticated = authStatus === 'authenticated';
+
+ return (
+
+
+
+ {lastError &&
{lastError}
}
+
+
+
+
+ Login
+
+
+ Demo users: shashwat / shashwat, nero / nero
+
+
+
+
+
+
Market Snapshot
+
+
+ Subscribe
+
+
+ Unsubscribe
+
+
+
+
+
+
+
+
+ {!subscribed && authenticated && (
+ Subscribe to receive live snapshots every second.
+ )}
+
+
+
+ Event Log
+
+ {log.length === 0 && No events yet }
+ {log.map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/hooks/useExchangeSocket.ts b/frontend/src/hooks/useExchangeSocket.ts
new file mode 100644
index 0000000..448de7c
--- /dev/null
+++ b/frontend/src/hooks/useExchangeSocket.ts
@@ -0,0 +1,184 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ DEFAULT_WS_URL,
+ MARKET_CHANNEL,
+ type AuthStatus,
+ type ConnectionStatus,
+ type MarketSnapshot,
+ type ServerMessage,
+} from '../types';
+
+function isMarketSnapshot(msg: ServerMessage): msg is MarketSnapshot {
+ return (
+ typeof msg === 'object' &&
+ msg !== null &&
+ 'ask' in msg &&
+ 'bid' in msg &&
+ !('type' in msg)
+ );
+}
+
+export function useExchangeSocket(url: string = DEFAULT_WS_URL) {
+ const wsRef = useRef(null);
+ const [connectionStatus, setConnectionStatus] =
+ useState('disconnected');
+ const [authStatus, setAuthStatus] = useState('logged_out');
+ const [username, setUsername] = useState(null);
+ const [snapshot, setSnapshot] = useState(null);
+ const [subscribed, setSubscribed] = useState(false);
+ const [lastError, setLastError] = useState(null);
+ const [log, setLog] = useState([]);
+
+ const appendLog = useCallback((line: string) => {
+ setLog((prev) => [line, ...prev].slice(0, 40));
+ }, []);
+
+ const send = useCallback((payload: Record) => {
+ const ws = wsRef.current;
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
+ setLastError('Socket is not connected');
+ return false;
+ }
+ ws.send(JSON.stringify(payload));
+ return true;
+ }, []);
+
+ const disconnect = useCallback(() => {
+ if (wsRef.current) {
+ wsRef.current.close();
+ wsRef.current = null;
+ }
+ setConnectionStatus('disconnected');
+ setAuthStatus('logged_out');
+ setUsername(null);
+ setSnapshot(null);
+ setSubscribed(false);
+ }, []);
+
+ const connect = useCallback(() => {
+ if (
+ wsRef.current &&
+ (wsRef.current.readyState === WebSocket.OPEN ||
+ wsRef.current.readyState === WebSocket.CONNECTING)
+ ) {
+ return;
+ }
+
+ setConnectionStatus('connecting');
+ setLastError(null);
+ appendLog(`Connecting to ${url}…`);
+
+ const ws = new WebSocket(url);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ setConnectionStatus('connected');
+ appendLog('Connected');
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const msg = JSON.parse(String(event.data)) as ServerMessage;
+
+ if (isMarketSnapshot(msg)) {
+ setSnapshot(msg);
+ return;
+ }
+
+ if (!('type' in msg)) {
+ return;
+ }
+
+ switch (msg.type) {
+ case 'login_response':
+ if (msg.status === 'success' || msg.status === 'already_logged_in') {
+ setAuthStatus('authenticated');
+ appendLog(`Login ${msg.status}`);
+ } else {
+ setAuthStatus('failed');
+ setLastError('Login failed — check credentials');
+ appendLog('Login failed');
+ }
+ break;
+ case 'subscribe_response':
+ setSubscribed(true);
+ appendLog(`Subscribed to ${msg.channel}`);
+ break;
+ case 'unsubscribe_response':
+ setSubscribed(false);
+ setSnapshot(null);
+ appendLog(`Unsubscribed from ${msg.channel}`);
+ break;
+ case 'error':
+ setLastError(msg.message);
+ appendLog(`Error: ${msg.message}`);
+ break;
+ default:
+ appendLog(`Message: ${JSON.stringify(msg)}`);
+ }
+ } catch {
+ appendLog(`Non-JSON message: ${String(event.data)}`);
+ }
+ };
+
+ ws.onerror = () => {
+ setConnectionStatus('error');
+ setLastError(
+ 'WebSocket error. For self-signed certs, trust certs/cert.pem or open the backend URL once in the browser and accept the warning.',
+ );
+ appendLog('WebSocket error');
+ };
+
+ ws.onclose = () => {
+ setConnectionStatus('disconnected');
+ setAuthStatus('logged_out');
+ setUsername(null);
+ setSubscribed(false);
+ wsRef.current = null;
+ appendLog('Disconnected');
+ };
+ }, [appendLog, url]);
+
+ const login = useCallback(
+ (user: string, password: string) => {
+ setAuthStatus('logging_in');
+ setUsername(user);
+ setLastError(null);
+ const ok = send({ type: 'login', username: user, password });
+ if (!ok) {
+ setAuthStatus('logged_out');
+ setUsername(null);
+ }
+ },
+ [send],
+ );
+
+ const subscribe = useCallback(() => {
+ send({ type: 'subscribe', channel: MARKET_CHANNEL });
+ }, [send]);
+
+ const unsubscribe = useCallback(() => {
+ send({ type: 'unsubscribe', channel: MARKET_CHANNEL });
+ }, [send]);
+
+ useEffect(() => {
+ return () => {
+ wsRef.current?.close();
+ };
+ }, []);
+
+ return {
+ connectionStatus,
+ authStatus,
+ username,
+ snapshot,
+ subscribed,
+ lastError,
+ log,
+ connect,
+ disconnect,
+ login,
+ subscribe,
+ unsubscribe,
+ };
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 4989b25..c069a83 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,64 +1,293 @@
:root {
- --text: #6b6375;
- --text-h: #08060d;
- --bg: #fff;
- --border: #e5e4e7;
- --code-bg: #f4f3ec;
- --accent: #aa3bff;
- --accent-bg: rgba(170, 59, 255, 0.1);
- --accent-border: rgba(170, 59, 255, 0.5);
- --social-bg: rgba(244, 243, 236, 0.5);
- --shadow:
- rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
-
+ --text: #9ca3af;
+ --text-h: #f3f4f6;
+ --bg: #0f1115;
+ --panel: #161a22;
+ --border: #2a2f3a;
+ --accent: #7c5cff;
+ --accent-hover: #9178ff;
+ --bid: #34d399;
+ --ask: #f87171;
+ --error-bg: rgba(248, 113, 113, 0.12);
+ --error-border: rgba(248, 113, 113, 0.4);
+ --muted: #6b7280;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
- --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
- --mono: ui-monospace, Consolas, monospace;
+ --mono: ui-monospace, 'Cascadia Code', Consolas, monospace;
- font: 18px/145% var(--sans);
- letter-spacing: 0.18px;
- color-scheme: light dark;
+ color-scheme: dark;
+ font: 15px/1.45 var(--sans);
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-
- @media (max-width: 1024px) {
- font-size: 16px;
- }
}
-@media (prefers-color-scheme: dark) {
- :root {
- --text: #9ca3af;
- --text-h: #f3f4f6;
- --bg: #16171d;
- --border: #2e303a;
- --code-bg: #1f2028;
- --accent: #c084fc;
- --accent-bg: rgba(192, 132, 252, 0.15);
- --accent-border: rgba(192, 132, 252, 0.5);
- --social-bg: rgba(47, 48, 58, 0.5);
- --shadow:
- rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
- }
+* {
+ box-sizing: border-box;
+}
+body {
+ margin: 0;
+ min-height: 100svh;
}
#root {
- width: 1126px;
- max-width: 100%;
+ max-width: 960px;
margin: 0 auto;
- text-align: center;
- border-inline: 1px solid var(--border);
- min-height: 100svh;
+ padding: 1.5rem 1.25rem 3rem;
+}
+
+h1,
+h2,
+h3 {
+ color: var(--text-h);
+ margin: 0;
+ font-weight: 600;
+}
+
+h1 {
+ font-size: 1.5rem;
+ letter-spacing: -0.02em;
+}
+
+h2 {
+ font-size: 1.05rem;
+ margin-bottom: 0.85rem;
+}
+
+h3 {
+ font-size: 0.95rem;
+ margin-bottom: 0.5rem;
+}
+
+.subtitle {
+ margin: 0.2rem 0 0;
+ color: var(--muted);
+ font-size: 0.9rem;
+}
+
+.app {
display: flex;
flex-direction: column;
- box-sizing: border-box;
+ gap: 1rem;
}
-body {
+.header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.status-pills {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.pill {
+ border: 1px solid var(--border);
+ background: var(--panel);
+ border-radius: 999px;
+ padding: 0.3rem 0.75rem;
+ font-size: 0.8rem;
+ text-transform: capitalize;
+}
+
+.pill--connected,
+.pill--authenticated {
+ border-color: color-mix(in srgb, var(--bid) 50%, transparent);
+ color: var(--bid);
+}
+
+.pill--connecting,
+.pill--logging_in {
+ border-color: color-mix(in srgb, #fbbf24 50%, transparent);
+ color: #fbbf24;
+}
+
+.pill--error,
+.pill--failed,
+.pill--disconnected,
+.pill--logged_out {
+ color: var(--muted);
+}
+
+.panel {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 1rem 1.1rem 1.15rem;
+}
+
+.row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 0.75rem;
+}
+
+.row.between {
+ justify-content: space-between;
+ align-items: center;
+}
+
+.row.gap {
+ gap: 0.5rem;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ min-width: 10rem;
+}
+
+.field.grow {
+ flex: 1;
+ min-width: 16rem;
+}
+
+.field span {
+ font-size: 0.78rem;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+input {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ color: var(--text-h);
+ padding: 0.55rem 0.7rem;
+ font: inherit;
+}
+
+input:focus {
+ outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
+ outline-offset: 1px;
+}
+
+input:disabled {
+ opacity: 0.55;
+}
+
+.btn {
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text-h);
+ border-radius: 8px;
+ padding: 0.55rem 0.95rem;
+ font: inherit;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.btn:hover:not(:disabled) {
+ border-color: color-mix(in srgb, var(--text-h) 30%, var(--border));
+}
+
+.btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.btn--primary {
+ background: var(--accent);
+ border-color: transparent;
+ color: white;
+}
+
+.btn--primary:hover:not(:disabled) {
+ background: var(--accent-hover);
+}
+
+.hint {
+ margin: 0.75rem 0 0;
+ color: var(--muted);
+ font-size: 0.85rem;
+}
+
+code,
+.mono {
+ font-family: var(--mono);
+ font-size: 0.88em;
+}
+
+.banner {
+ border-radius: 10px;
+ padding: 0.75rem 1rem;
+ font-size: 0.9rem;
+}
+
+.banner--error {
+ background: var(--error-bg);
+ border: 1px solid var(--error-border);
+ color: #fecaca;
+}
+
+.book {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+}
+
+@media (max-width: 640px) {
+ .book {
+ grid-template-columns: 1fr;
+ }
+}
+
+.book-side table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.92rem;
+}
+
+.book-side th,
+.book-side td {
+ text-align: left;
+ padding: 0.35rem 0.25rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.book-side th {
+ color: var(--muted);
+ font-weight: 500;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.book-side--bid td.mono {
+ color: var(--bid);
+}
+
+.book-side--ask td.mono {
+ color: var(--ask);
+}
+
+.log {
+ list-style: none;
margin: 0;
+ padding: 0;
+ max-height: 12rem;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.log li {
+ padding: 0.25rem 0;
+ border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
+ font-size: 0.82rem;
+ color: var(--text);
+}
+
+.muted {
+ color: var(--muted);
}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
new file mode 100644
index 0000000..ac8e9d7
--- /dev/null
+++ b/frontend/src/types.ts
@@ -0,0 +1,30 @@
+export type ConnectionStatus =
+ | 'disconnected'
+ | 'connecting'
+ | 'connected'
+ | 'error';
+
+export type AuthStatus = 'logged_out' | 'logging_in' | 'authenticated' | 'failed';
+
+export type BookLevels = {
+ level_1: number;
+ level_2: number;
+ level_3: number;
+ level_4: number;
+ level_5: number;
+};
+
+export type MarketSnapshot = {
+ ask: BookLevels;
+ bid: BookLevels;
+};
+
+export type ServerMessage =
+ | { type: 'login_response'; status: 'success' | 'failed' | 'already_logged_in' }
+ | { type: 'subscribe_response'; status: string; channel: string }
+ | { type: 'unsubscribe_response'; status: string; channel: string }
+ | { type: 'error'; message: string }
+ | MarketSnapshot;
+
+export const MARKET_CHANNEL = 'market/snapshot';
+export const DEFAULT_WS_URL = 'wss://localhost:7000';