diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..45a866b
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,49 @@
+target
+**/target
+**/node_modules
+**/.next
+**/.pnpm-store
+**/.turbo
+**/.cache
+**/dist
+**/build
+.env
+.env.*
+docker-compose.yml
+
+# Dependencies
+node_modules
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Next.js
+.next
+out
+build
+
+# Environment files
+.env*.local
+.env
+
+# Git
+.git
+.gitignore
+
+# IDE
+.vscode
+.idea
+*.swp
+*.swo
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Testing
+coverage
+.nyc_output
+
+# Misc
+*.log
+.cache
\ No newline at end of file
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..f16cd5a
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,6 @@
+ENV=dev | prod
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=password
+POSTGRES_DB=polkadot_clob
+POSTGRES_DB_FULL_NAME=${POSTGRES_DB}_${ENV}
+INDEXER_PORT=8081 (suggeted, can be every available port)
diff --git a/.gitignore b/.gitignore
index 05a7bb6..0e06210 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,34 @@
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
-
+.env
target/
+
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
\ No newline at end of file
diff --git a/README.md b/README.md
index b59d352..87b0175 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,18 @@ This project implements a fully functional orderbook-based DEX on Substrate, fea
- **Custom Assets Pallet**: Manages USDT and ETH balances with lock/unlock functionality
- **Atomic Settlement**: All trades are settled atomically with proper fund transfers
+## Quick Start
+Pull mock data:
+```bash
+cd tradebot
+git lfs pull
+```
+
+From root directory:
+```bash
+docker compose up --build
+```
+
## Architecture
### Two-Phase Design
diff --git a/docker-compose.yml b/docker-compose.yml
index 21971f7..2ba3354 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,12 +1,32 @@
services:
+ solochain:
+ image: solochain-node:latest
+ container_name: solochain
+ command:
+ - --dev
+ - --rpc-cors=all
+ - --rpc-methods=Unsafe
+ - --unsafe-rpc-external
+ - --base-path
+ - /data
+ ports:
+ # ws port 9944
+ - "9944:9944"
+ - "9933:9933"
+ - "9615:9615"
+ - "30333:30333"
+ volumes:
+ - solochain-data:/data
+ restart: unless-stopped
+
timescaledb:
image: timescale/timescaledb-ha:pg17
ports:
- "5432:5432"
environment:
- - POSTGRES_USER=postgres
- - POSTGRES_PASSWORD=password
- - POSTGRES_DB=polkadot_clob
+ - POSTGRES_USER=${POSTGRES_USER}
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
+ - POSTGRES_DB=${POSTGRES_DB}
volumes:
- ./indexer/db/timescale.sql:/mnt/timescale.sql
restart: unless-stopped
@@ -15,3 +35,53 @@ services:
interval: 10s
timeout: 5s
retries: 5
+
+ indexer:
+ build:
+ context: .
+ dockerfile: indexer/Dockerfile
+ container_name: indexer
+ depends_on:
+ timescaledb:
+ condition: service_healthy
+ solochain:
+ condition: service_started
+ environment:
+ Env: ${ENV}
+ NODE_WS_URL: ws://solochain:9944
+ DATABASE_URL: postgres://postgres:password@timescaledb:5432/${POSTGRES_DB_FULL_NAME}
+ RUST_LOG: indexer=debug,info
+ ports:
+ - "8081:3000"
+ restart: unless-stopped
+
+ frontend:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ container_name: frontend
+ ports:
+ - "3000:3000"
+ env_file:
+ - ./frontend/.env
+ restart: unless-stopped
+
+ tradebot:
+ build:
+ context: .
+ dockerfile: tradebot/Dockerfile
+ container_name: tradebot
+ depends_on:
+ timescaledb:
+ condition: service_healthy
+ solochain:
+ condition: service_started
+ env_file:
+ - ./tradebot/.env
+ volumes:
+ - ./tradebot/ETHUSDC_2025-11-12T22-08-37-339Z_synthetic_blocks.jsonl:/app/ETHUSDC_2025-11-12T22-08-37-339Z_synthetic_blocks.jsonl:ro
+ restart: unless-stopped
+
+volumes:
+ pgdata:
+ solochain-data:
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..f217141
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,36 @@
+# Dependencies
+node_modules
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Next.js
+.next
+out
+build
+
+# Environment files
+.env*.local
+.env
+
+# Git
+.git
+.gitignore
+
+# IDE
+.vscode
+.idea
+*.swp
+*.swo
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Testing
+coverage
+.nyc_output
+
+# Misc
+*.log
+.cache
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..f650315
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,27 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
\ No newline at end of file
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..5e3e030
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,31 @@
+# ----- base -----
+FROM node:20-alpine AS base
+WORKDIR /app
+RUN apk add --no-cache libc6-compat && corepack enable
+
+# Only lockfile + manifest first for better caching
+COPY package.json pnpm-lock.yaml ./
+RUN corepack prepare pnpm@9 --activate && pnpm fetch
+
+# ----- build -----
+FROM base AS build
+COPY . .
+# If you removed the workspace file, this installs just this app
+RUN pnpm install --no-frozen-lockfile
+RUN pnpm build
+
+# ----- runtime -----
+FROM node:20-alpine AS runtime
+WORKDIR /app
+RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
+
+# Copy minimal runtime artifacts
+COPY --from=build /app/.next ./.next
+COPY --from=build /app/public ./public
+COPY --from=build /app/package.json .
+COPY --from=build /app/node_modules ./node_modules
+COPY --from=build /app/next.config.mjs ./next.config.mjs
+
+EXPOSE 3000
+USER nextjs
+CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "3000"]
diff --git a/frontend/README.docker.md b/frontend/README.docker.md
new file mode 100644
index 0000000..2fee2c2
--- /dev/null
+++ b/frontend/README.docker.md
@@ -0,0 +1,62 @@
+# Docker Setup for Orbex
+
+## Prerequisites
+- Docker installed on your system
+- Docker Compose installed
+
+## Environment Variables
+
+The application requires the following environment variables:
+- `NODE_ENV`: Set to `prod` for production
+- `SERVER_URL`: API server URL (default: http://localhost:3000)
+- `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`: Your WalletConnect project ID
+
+## Quick Start
+
+### 1. Build and run with Docker Compose
+
+\`\`\`bash
+docker-compose up --build
+\`\`\`
+
+The application will be available at http://localhost:3000
+
+### 2. Run in detached mode
+
+\`\`\`bash
+docker-compose up -d
+\`\`\`
+
+### 3. Stop the application
+
+\`\`\`bash
+docker-compose down
+\`\`\`
+
+## Manual Docker Commands
+
+### Build the image
+
+\`\`\`bash
+docker build -t orbex-web-app .
+\`\`\`
+
+### Run the container
+
+\`\`\`bash
+docker run -p 3000:3000 \
+ -e NODE_ENV=prod \
+ -e SERVER_URL=http://localhost:3000 \
+ -e NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your-project-id \
+ orbex-web-app
+\`\`\`
+
+## Development
+
+To override environment variables, create a `.env` file or modify the `docker-compose.yml` file.
+
+## Troubleshooting
+
+- If port 3000 is already in use, modify the port mapping in `docker-compose.yml`
+- Check logs: `docker-compose logs -f`
+- Rebuild after code changes: `docker-compose up --build`
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
new file mode 100644
index 0000000..29a1e34
--- /dev/null
+++ b/frontend/app/globals.css
@@ -0,0 +1,126 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ --background: hsl(0 0% 100%);
+ --foreground: hsl(215 25% 18%);
+ --card: hsl(0 0% 100%);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: hsl(0 0% 100%);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: hsl(215 45% 32%);
+ --primary-foreground: oklch(1 0 0);
+ --secondary: hsl(215 15% 93%);
+ --secondary-foreground: oklch(0.145 0 0);
+ --muted: hsl(215 15% 93%);
+ --muted-foreground: oklch(0.5 0 0);
+ --accent: hsl(215 25% 85%);
+ --accent-foreground: oklch(1 0 0);
+ --destructive: hsl(0 84.2% 60.2%);
+ --destructive-foreground: oklch(1 0 0);
+ --border: hsl(215 18% 88%);
+ --input: hsl(215 18% 88%);
+ --ring: hsl(215 45% 32%);
+ --chart-1: hsl(215 45% 32%);
+ --chart-2: hsl(220 38% 45%);
+ --chart-3: hsl(210 32% 58%);
+ --chart-4: hsl(205 25% 68%);
+ --chart-5: hsl(218 52% 22%);
+ --radius: 0.5rem;
+ --sidebar: hsl(215 12% 97%);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: hsl(222.2 84% 4.9%);
+ --foreground: hsl(210 40% 98%);
+ --card: hsl(222.2 84% 4.9%);
+ --card-foreground: oklch(0.98 0 0);
+ --popover: hsl(222.2 84% 4.9%);
+ --popover-foreground: oklch(0.98 0 0);
+ --primary: hsl(210 40% 98%);
+ --primary-foreground: oklch(0.12 0 0);
+ --secondary: hsl(217.2 32.6% 17.5%);
+ --secondary-foreground: oklch(0.98 0 0);
+ --muted: hsl(217.2 32.6% 17.5%);
+ --muted-foreground: oklch(0.65 0 0);
+ --accent: hsl(217.2 32.6% 17.5%);
+ --accent-foreground: oklch(0.98 0 0);
+ --destructive: hsl(0 62.8% 30.6%);
+ --destructive-foreground: oklch(1 0 0);
+ --border: hsl(217.2 32.6% 17.5%);
+ --input: hsl(217.2 32.6% 17.5%);
+ --ring: hsl(212.7 26.8% 83.9%);
+ --chart-1: hsl(220 70% 50%);
+ --chart-2: hsl(160 60% 45%);
+ --chart-3: hsl(30 80% 55%);
+ --chart-4: hsl(280 65% 60%);
+ --chart-5: hsl(340 75% 55%);
+ --sidebar: hsl(240 5.9% 10%);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(0.269 0 0);
+ --sidebar-ring: oklch(0.439 0 0);
+}
+
+@theme inline {
+ /* optional: --font-sans, --font-serif, --font-mono if they are applied in the layout.tsx */
+ --font-sans: "Geist", "Geist Fallback";
+ --font-mono: "Geist Mono", "Geist Mono Fallback";
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
new file mode 100644
index 0000000..095acf2
--- /dev/null
+++ b/frontend/app/layout.tsx
@@ -0,0 +1,28 @@
+import type React from 'react'
+import type { Metadata } from 'next'
+import { Geist, Geist_Mono } from 'next/font/google'
+import './globals.css'
+import { ThemeProvider } from '@/components/theme-provider'
+
+// ✅ import the client component directly
+import { Providers } from '@/components/providers'
+
+const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'] })
+const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'] })
+
+export const metadata: Metadata = {
+ title: 'Orbex',
+ description: 'Modern crypto trading platform',
+}
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+
+ )
+}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
new file mode 100644
index 0000000..8d4dc0d
--- /dev/null
+++ b/frontend/app/page.tsx
@@ -0,0 +1,9 @@
+import { TradingDashboard } from "@/components/trading-dashboard"
+
+export default function Home() {
+ return (
+
+
+
+ )
+}
diff --git a/frontend/components.json b/frontend/components.json
new file mode 100644
index 0000000..4ee62ee
--- /dev/null
+++ b/frontend/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
diff --git a/frontend/components/account-tabs.tsx b/frontend/components/account-tabs.tsx
new file mode 100644
index 0000000..db8a58a
--- /dev/null
+++ b/frontend/components/account-tabs.tsx
@@ -0,0 +1,178 @@
+"use client"
+
+import { useState } from "react"
+import { useBalances } from "@/hooks/use-balances"
+import { usePositions } from "@/hooks/use-positions"
+import { useOpenOrders } from "@/hooks/use-open-orders"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Card, CardContent } from "@/components/ui/card"
+
+export function AccountTabs() {
+ const [activeTab, setActiveTab] = useState("balances")
+
+ const balances = useBalances()
+ const positions = usePositions()
+ const openOrders = useOpenOrders()
+
+ return (
+
+
+
+
+
+ Balances
+
+
+ Positions
+
+
+ Open Orders
+
+
+ TWAP
+
+
+ Trade History
+
+
+ Funding History
+
+
+ Order History
+
+
+
+
+
+
+
+
+ | Asset |
+ Amount |
+ Available |
+ Value |
+
+
+
+ {balances.map((balance) => (
+
+ | {balance.asset} |
+ {balance.amount} |
+ {balance.available} |
+ {balance.value} |
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ | Pair |
+ Side |
+ Size |
+ Entry Price |
+ Mark Price |
+ PnL |
+
+
+
+ {positions.map((position, idx) => (
+
+ | {position.pair} |
+
+
+ {position.side}
+
+ |
+ {position.size} |
+ {position.entryPrice} |
+ {position.markPrice} |
+
+ {position.pnl} ({position.pnlPercent})
+ |
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ | Pair |
+ Type |
+ Side |
+ Price |
+ Amount |
+ Filled |
+ Total |
+
+
+
+ {openOrders.map((order, idx) => (
+
+ | {order.pair} |
+ {order.type} |
+
+ {order.side}
+ |
+ {order.price} |
+ {order.amount} |
+ {order.filled} |
+ {order.total} |
+
+ ))}
+
+
+
+
+
+
+ No TWAP orders
+
+
+
+ No trade history
+
+
+
+ No funding history
+
+
+
+ No order history
+
+
+
+
+ )
+}
diff --git a/frontend/components/market-stats.tsx b/frontend/components/market-stats.tsx
new file mode 100644
index 0000000..0b249c4
--- /dev/null
+++ b/frontend/components/market-stats.tsx
@@ -0,0 +1,95 @@
+"use client"
+
+import { Card, CardContent } from "@/components/ui/card"
+import { TrendingUp, TrendingDown } from "lucide-react"
+
+interface MarketStatsProps {
+ selectedPair: string
+}
+
+export function MarketStats({ selectedPair }: MarketStatsProps) {
+ // Mock data - in production, fetch from API
+ const stats = {
+ "BTC/USD": {
+ price: "67,234.50",
+ change: "+2.34",
+ changePercent: "+3.61%",
+ high24h: "68,450.00",
+ low24h: "65,120.00",
+ volume24h: "28.5B",
+ isPositive: true,
+ },
+ "ETH/USD": {
+ price: "3,456.78",
+ change: "-45.23",
+ changePercent: "-1.29%",
+ high24h: "3,520.00",
+ low24h: "3,401.00",
+ volume24h: "12.3B",
+ isPositive: false,
+ },
+ "SOL/USD": {
+ price: "142.56",
+ change: "+8.92",
+ changePercent: "+6.68%",
+ high24h: "145.00",
+ low24h: "135.20",
+ volume24h: "2.1B",
+ isPositive: true,
+ },
+ }
+
+ const currentStats = stats[selectedPair as keyof typeof stats]
+
+ return (
+
+
+
+
+
Price
+
+
${currentStats.price}
+
+ {currentStats.isPositive ? : }
+ {currentStats.changePercent}
+
+
+
+
+
+
+
+
+
+
24h Change
+
+ ${currentStats.change}
+
+
+
+
+
+
+
+
+
24h High
+
${currentStats.high24h}
+
+
+
+
+
+
+
+
24h Volume
+
${currentStats.volume24h}
+
+
+
+
+ )
+}
diff --git a/frontend/components/order-book.tsx b/frontend/components/order-book.tsx
new file mode 100644
index 0000000..122f300
--- /dev/null
+++ b/frontend/components/order-book.tsx
@@ -0,0 +1,196 @@
+"use client"
+
+import { useState, useEffect, useRef } from "react"
+import { useOrderBook } from "@/hooks/use-order-book"
+import { useTrades } from "@/hooks/use-trades"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+
+function OrderBookRow({
+ price,
+ size,
+ total,
+ sizeNum,
+ maxSize,
+ type,
+ priceGrouping,
+}: {
+ price: string
+ size: string
+ total: string
+ sizeNum: number
+ maxSize: number
+ type: "ask" | "bid"
+ priceGrouping: number
+}) {
+ const [flash, setFlash] = useState(false)
+ const prevSizeRef = useRef(sizeNum)
+
+ useEffect(() => {
+ // Detect size changes and trigger flash animation
+ if (prevSizeRef.current !== sizeNum) {
+ setFlash(true)
+ const timer = setTimeout(() => setFlash(false), 300)
+ prevSizeRef.current = sizeNum
+ return () => clearTimeout(timer)
+ }
+ }, [sizeNum])
+
+ // Calculate heatmap intensity (0-1)
+ const intensity = maxSize > 0 ? sizeNum / maxSize : 0
+
+ const bgGradient =
+ type === "ask"
+ ? `linear-gradient(to right, transparent ${100 - intensity * 100}%, rgba(239, 68, 68, ${intensity * 0.3}) ${100 - intensity * 100}%)`
+ : `linear-gradient(to right, transparent ${100 - intensity * 100}%, rgba(34, 197, 94, ${intensity * 0.3}) ${100 - intensity * 100}%)`
+
+ const formatPrice = (priceStr: string, grouping: number): string => {
+ const priceNum = Number(priceStr)
+
+ if (grouping >= 1) {
+ // Round up to nearest multiple of grouping
+ const rounded = Math.ceil(priceNum / grouping) * grouping
+ return rounded.toFixed(0)
+ } else {
+ // For decimal groupings, use decimal places
+ const decimals = Math.max(0, -Math.log10(grouping))
+ return priceNum.toFixed(decimals)
+ }
+ }
+
+ const formattedPrice = formatPrice(price, priceGrouping)
+
+ return (
+
+
{formattedPrice}
+
{size}
+
{total}
+
+ )
+}
+
+export function OrderBook() {
+ const [activeTab, setActiveTab] = useState<"orderbook" | "trades">("orderbook")
+ const [priceGrouping, setPriceGrouping] = useState(0.01)
+
+ const { asks, bids, spread, spreadPercent, maxSize } = useOrderBook()
+ const trades = useTrades()
+
+ return (
+
+
+
+
+
+
+ {activeTab === "orderbook" ? (
+ <>
+
+
+
+
+
+
Price
+
+ Size ETH
+
+
Total
+
+
+
+
+ {asks.map((ask, i) => (
+
+ ))}
+
+
+
+
+ {spread}
+ Spread
+ {spreadPercent}%
+
+
+
+
+ {bids.map((bid, i) => (
+
+ ))}
+
+
+ >
+ ) : (
+ <>
+
+
+
+ {trades.map((trade, i) => (
+
+
{trade.price}
+
{trade.size}
+
{trade.time}
+
+ ))}
+
+ >
+ )}
+
+ )
+}
diff --git a/frontend/components/providers.tsx b/frontend/components/providers.tsx
new file mode 100644
index 0000000..9e4aabd
--- /dev/null
+++ b/frontend/components/providers.tsx
@@ -0,0 +1,30 @@
+'use client'
+
+import type React from 'react'
+import { useMemo, useState } from 'react'
+
+import { WagmiProvider, createConfig, http } from 'wagmi'
+import { mainnet } from 'wagmi/chains'
+// IMPORTANT: use injected() instead of metaMask() to avoid pulling @metamask/sdk
+import { injected } from 'wagmi/connectors'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ const [queryClient] = useState(() => new QueryClient())
+
+ // Create config on the client; injected() does not touch indexedDB/RN storage.
+ const config = useMemo(() => {
+ return createConfig({
+ chains: [mainnet],
+ transports: { [mainnet.id]: http() },
+ connectors: [injected()],
+ // Do NOT enable any persistence layer that uses indexedDB on module load.
+ })
+ }, [])
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/frontend/components/theme-provider.tsx b/frontend/components/theme-provider.tsx
new file mode 100644
index 0000000..1cd216d
--- /dev/null
+++ b/frontend/components/theme-provider.tsx
@@ -0,0 +1,7 @@
+"use client"
+import { ThemeProvider as NextThemesProvider } from "next-themes"
+import type { ThemeProviderProps } from "next-themes"
+
+export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
+ return {children}
+}
diff --git a/frontend/components/theme-toggle.tsx b/frontend/components/theme-toggle.tsx
new file mode 100644
index 0000000..1ed9cbf
--- /dev/null
+++ b/frontend/components/theme-toggle.tsx
@@ -0,0 +1,22 @@
+"use client"
+import { Moon, Sun } from "lucide-react"
+import { useTheme } from "next-themes"
+
+import { Button } from "@/components/ui/button"
+
+export function ThemeToggle() {
+ const { theme, setTheme } = useTheme()
+
+ return (
+
+ )
+}
diff --git a/frontend/components/trading-dashboard.tsx b/frontend/components/trading-dashboard.tsx
new file mode 100644
index 0000000..9bdd945
--- /dev/null
+++ b/frontend/components/trading-dashboard.tsx
@@ -0,0 +1,155 @@
+"use client"
+
+import { useState } from "react"
+import { Button } from "@/components/ui/button"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { TradingViewChart, SimulatedTVChart } from "@/components/trading-view-chart"
+import { WalletConnect } from "@/components/wallet-connect"
+import { OrderBook } from "@/components/order-book"
+import { TradingForm } from "@/components/trading-form"
+import { AccountTabs } from "@/components/account-tabs"
+import { ThemeToggle } from "@/components/theme-toggle"
+import { Activity, TrendingUp, TrendingDown } from "lucide-react"
+
+export function TradingDashboard() {
+ const [selectedPair, setSelectedPair] = useState("BTC/USD")
+
+ const stats = {
+ "BTC/USD": {
+ price: "67,234.50",
+ change: "+2.34",
+ changePercent: "+3.61%",
+ high24h: "68,450.00",
+ low24h: "65,120.00",
+ volume24h: "28.5B",
+ isPositive: true,
+ },
+ "ETH/USD": {
+ price: "3,456.78",
+ change: "-45.23",
+ changePercent: "-1.29%",
+ high24h: "3,520.00",
+ low24h: "3,401.00",
+ volume24h: "12.3B",
+ isPositive: false,
+ },
+ "SOL/USD": {
+ price: "142.56",
+ change: "+8.92",
+ changePercent: "+6.68%",
+ high24h: "145.00",
+ low24h: "135.20",
+ volume24h: "2.1B",
+ isPositive: true,
+ },
+ }
+
+ const currentStats = stats[selectedPair as keyof typeof stats]
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Main Content */}
+
+ {/* Middle Section - Chart, Order Book, and Trading Form */}
+
+ {/* Left Section - Chart and Order Book */}
+
+ {/* Chart and Order Book Row */}
+
+ {/* Trading Chart */}
+
+
+
+
+
+
${currentStats.price}
+
+ {currentStats.isPositive ? (
+
+ ) : (
+
+ )}
+ {currentStats.changePercent}
+
+
+
+
+
+
+
+
+
+ {/*
*/}
+
+ {/*
+
+
*/}
+
+
+
+ {/* Order Book */}
+
+
+
+
+
+
+
+
+ {/* Right Column - Trading Form */}
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/components/trading-form.tsx b/frontend/components/trading-form.tsx
new file mode 100644
index 0000000..2bb2df0
--- /dev/null
+++ b/frontend/components/trading-form.tsx
@@ -0,0 +1,207 @@
+"use client"
+
+import { useState } from "react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Slider } from "@/components/ui/slider"
+import { useToast } from "@/hooks/use-toast"
+
+interface TradingFormProps {
+ selectedPair: string
+}
+
+export function TradingForm({ selectedPair }: TradingFormProps) {
+ const [buyAmount, setBuyAmount] = useState("")
+ const [buyPrice, setBuyPrice] = useState("")
+ const [sellAmount, setSellAmount] = useState("")
+ const [sellPrice, setSellPrice] = useState("")
+ const [buyPercentage, setBuyPercentage] = useState([0])
+ const [sellPercentage, setSellPercentage] = useState([0])
+ const { toast } = useToast()
+
+ const handleBuy = () => {
+ toast({
+ title: "Buy Order Placed",
+ description: `Buying ${buyAmount} ${selectedPair.split("/")[0]} at $${buyPrice}`,
+ })
+ setBuyAmount("")
+ setBuyPrice("")
+ setBuyPercentage([0])
+ }
+
+ const handleSell = () => {
+ toast({
+ title: "Sell Order Placed",
+ description: `Selling ${sellAmount} ${selectedPair.split("/")[0]} at $${sellPrice}`,
+ })
+ setSellAmount("")
+ setSellPrice("")
+ setSellPercentage([0])
+ }
+
+ return (
+
+
+
+
+
+
+ Buy
+
+
+ Sell
+
+
+
+
+
+
+ setBuyPrice(e.target.value)}
+ className="bg-background text-foreground"
+ />
+
+
+
+
+ setBuyAmount(e.target.value)}
+ className="bg-background text-foreground"
+ />
+
+
+
+
+
+ {buyPercentage[0]}%
+
+
+
+ 0%
+ 25%
+ 50%
+ 75%
+ 100%
+
+
+
+
+
+ Available Balance:
+ 10,000.00 USD
+
+
+ Total:
+
+ {buyAmount && buyPrice
+ ? (Number.parseFloat(buyAmount) * Number.parseFloat(buyPrice)).toFixed(2)
+ : "0.00"}{" "}
+ USD
+
+
+
+
+
+
+
+
+
+
+ setSellPrice(e.target.value)}
+ className="bg-background text-foreground"
+ />
+
+
+
+
+ setSellAmount(e.target.value)}
+ className="bg-background text-foreground"
+ />
+
+
+
+
+
+ {sellPercentage[0]}%
+
+
+
+ 0%
+ 25%
+ 50%
+ 75%
+ 100%
+
+
+
+
+
+ Available Balance:
+ 0.5234 {selectedPair.split("/")[0]}
+
+
+ Total:
+
+ {sellAmount && sellPrice
+ ? (Number.parseFloat(sellAmount) * Number.parseFloat(sellPrice)).toFixed(2)
+ : "0.00"}{" "}
+ USD
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/components/trading-view-chart.tsx b/frontend/components/trading-view-chart.tsx
new file mode 100644
index 0000000..ac86bc0
--- /dev/null
+++ b/frontend/components/trading-view-chart.tsx
@@ -0,0 +1,259 @@
+"use client"
+
+import { useEffect, useRef, memo, useMemo, useState } from "react"
+
+interface TradingViewChartProps {
+ symbol: string
+}
+
+export const TradingViewChart = memo(function TradingViewChart({ symbol }: TradingViewChartProps) {
+ const container = useRef(null)
+
+ useEffect(() => {
+ if (!container.current) return
+
+ // Clear previous widget
+ container.current.innerHTML = ""
+
+ const script = document.createElement("script")
+ script.src = "https://s3.tradingview.com/external-embedding/embed-widget-advanced-chart.js"
+ script.type = "text/javascript"
+ script.async = true
+ script.innerHTML = JSON.stringify({
+ autosize: true,
+ symbol: symbol.replace("/", ""),
+ interval: "D",
+ timezone: "Etc/UTC",
+ theme: "dark",
+ style: "1",
+ locale: "en",
+ enable_publishing: false,
+ backgroundColor: "rgba(22, 22, 22, 1)",
+ gridColor: "rgba(42, 42, 42, 1)",
+ hide_top_toolbar: false,
+ hide_legend: false,
+ save_image: false,
+ container_id: "tradingview_chart",
+ height: "600",
+ width: "100%",
+ })
+
+ container.current.appendChild(script)
+
+ return () => {
+ if (container.current) {
+ container.current.innerHTML = ""
+ }
+ }
+ }, [symbol])
+
+ return (
+
+ )
+})
+
+import {
+ createChart,
+ CandlestickSeries,
+ type IChartApi,
+ type ISeriesApi,
+ type CandlestickData,
+ type Time,
+} from "lightweight-charts"
+import { useSimulatedCandles } from "../hooks/use-simulated-candles"
+
+type SimulatedTVChartProps = {
+ symbol: string
+}
+
+type Timeframe = "1m" | "30m" | "1h" | "4h" | "1D"
+
+export function SimulatedTVChart({ symbol }: SimulatedTVChartProps) {
+ const containerRef = useRef(null)
+ const chartRef = useRef(null)
+ const seriesRef = useRef | null>(null)
+
+ const [timeframe, setTimeframe] = useState("1D")
+
+ const candleMs = useMemo(() => {
+ switch (timeframe) {
+ case "1m":
+ return 60_000
+ case "30m":
+ return 30 * 60_000
+ case "1h":
+ return 60 * 60_000
+ case "4h":
+ return 4 * 60 * 60_000
+ case "1D":
+ default:
+ return 24 * 60 * 60_000
+ }
+ }, [timeframe])
+
+ const candles = useSimulatedCandles({
+ symbol,
+ candleMs,
+ history: 30,
+ tickMs: 1000,
+ })
+
+ const lastCandle = useMemo(() => {
+ if (!candles.length) return null
+ return candles[candles.length - 1]
+ }, [candles])
+
+ const initialZoomDoneRef = useRef(false)
+
+ useEffect(() => {
+ initialZoomDoneRef.current = false
+ }, [timeframe])
+
+ useEffect(() => {
+ if (!containerRef.current || chartRef.current) return
+
+ const chart = createChart(containerRef.current, {
+ autoSize: true,
+ layout: {
+ background: { color: "rgba(22, 22, 22, 1)" },
+ textColor: "#d1d4dc",
+ },
+ grid: {
+ vertLines: { color: "rgba(42, 42, 42, 1)" },
+ horzLines: { color: "rgba(42, 42, 42, 1)" },
+ },
+
+ rightPriceScale: {
+ borderColor: "rgba(42, 42, 42, 1)",
+ },
+ timeScale: {
+ borderColor: "rgba(42, 42, 42, 1)",
+ timeVisible: true,
+ secondsVisible: false,
+ rightOffset: 5,
+ barSpacing: 10,
+ },
+ crosshair: {
+ mode: 1,
+ vertLine: {
+ color: "rgba(197, 203, 206, 0.6)",
+ width: 1,
+ },
+ horzLine: {
+ color: "rgba(197, 203, 206, 0.6)",
+ width: 1,
+ },
+ },
+ })
+
+ chartRef.current = chart
+
+ const candleSeries = chart.addSeries(CandlestickSeries, {
+ upColor: "#22c55e",
+ downColor: "#ef4444",
+ borderUpColor: "#22c55e",
+ borderDownColor: "#ef4444",
+ wickUpColor: "#22c55e",
+ wickDownColor: "#ef4444",
+ })
+
+ seriesRef.current = candleSeries
+
+ return () => {
+ chart.remove()
+ chartRef.current = null
+ seriesRef.current = null
+ }
+ }, [])
+
+ useEffect(() => {
+ if (!seriesRef.current) return
+
+ const data: CandlestickData[] = candles.map(c => ({
+ time: c.time as Time,
+ open: c.open,
+ high: c.high,
+ low: c.low,
+ close: c.close,
+ }))
+
+ seriesRef.current.setData(data)
+
+ if (!chartRef.current || data.length === 0) return
+
+ if (!initialZoomDoneRef.current) {
+ const total = data.length
+
+ // How many *real* candles you want visible:
+ const visible = 20
+ // How many "empty slots" you want to the right:
+ const emptyRight = 10
+
+ const actualVisible = Math.min(visible, total)
+ const from = Math.max(0, total - actualVisible)
+ const to = from + actualVisible - 1 + emptyRight
+
+ chartRef.current.timeScale().setVisibleLogicalRange({ from, to })
+ initialZoomDoneRef.current = true
+ }
+ }, [candles])
+
+
+ const close = lastCandle?.close ?? 0
+ const open = lastCandle?.open ?? 0
+ const high = lastCandle?.high ?? 0
+ const low = lastCandle?.low ?? 0
+ const diff = close - open
+ const pct = open ? (diff / open) * 100 : 0
+ const isUp = diff >= 0
+
+ const timeframes: Timeframe[] = ["1m", "30m", "1h", "4h", "1D"]
+
+ return (
+
+ {/* Header bar */}
+
+
+
+ {symbol}
+
+
+ {close.toFixed(2)}
+
+ {isUp ? "+" : ""}
+ {diff.toFixed(2)} ({pct.toFixed(2)}%)
+
+
+ O {open.toFixed(2)} · H {high.toFixed(2)} · L {low.toFixed(2)} · C {close.toFixed(2)}
+
+
+
+
+ {/* Timeframe buttons */}
+
+ {timeframes.map(tf => (
+
+ ))}
+
+
+
+ {/* Chart area */}
+
+
+ )
+}
diff --git a/frontend/components/ui/accordion.tsx b/frontend/components/ui/accordion.tsx
new file mode 100644
index 0000000..e538a33
--- /dev/null
+++ b/frontend/components/ui/accordion.tsx
@@ -0,0 +1,66 @@
+'use client'
+
+import * as React from 'react'
+import * as AccordionPrimitive from '@radix-ui/react-accordion'
+import { ChevronDownIcon } from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+
+function Accordion({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180',
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ )
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..9704452
--- /dev/null
+++ b/frontend/components/ui/alert-dialog.tsx
@@ -0,0 +1,157 @@
+'use client'
+
+import * as React from 'react'
+import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
+
+import { cn } from '@/lib/utils'
+import { buttonVariants } from '@/components/ui/button'
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/frontend/components/ui/alert.tsx b/frontend/components/ui/alert.tsx
new file mode 100644
index 0000000..e6751ab
--- /dev/null
+++ b/frontend/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const alertVariants = cva(
+ 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
+ {
+ variants: {
+ variant: {
+ default: 'bg-card text-card-foreground',
+ destructive:
+ 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/frontend/components/ui/aspect-ratio.tsx b/frontend/components/ui/aspect-ratio.tsx
new file mode 100644
index 0000000..40bb120
--- /dev/null
+++ b/frontend/components/ui/aspect-ratio.tsx
@@ -0,0 +1,11 @@
+'use client'
+
+import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'
+
+function AspectRatio({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+export { AspectRatio }
diff --git a/frontend/components/ui/avatar.tsx b/frontend/components/ui/avatar.tsx
new file mode 100644
index 0000000..aa98465
--- /dev/null
+++ b/frontend/components/ui/avatar.tsx
@@ -0,0 +1,53 @@
+'use client'
+
+import * as React from 'react'
+import * as AvatarPrimitive from '@radix-ui/react-avatar'
+
+import { cn } from '@/lib/utils'
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx
new file mode 100644
index 0000000..fc4126b
--- /dev/null
+++ b/frontend/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const badgeVariants = cva(
+ 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
+ {
+ variants: {
+ variant: {
+ default:
+ 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
+ secondary:
+ 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
+ destructive:
+ 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'span'> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : 'span'
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/frontend/components/ui/breadcrumb.tsx b/frontend/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..1750ff2
--- /dev/null
+++ b/frontend/components/ui/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { ChevronRight, MoreHorizontal } from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+
+function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
+ return
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<'a'> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : 'a'
+
+ return (
+
+ )
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<'li'>) {
+ return (
+ svg]:size-3.5', className)}
+ {...props}
+ >
+ {children ?? }
+
+ )
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<'span'>) {
+ return (
+
+
+ More
+
+ )
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/frontend/components/ui/button-group.tsx b/frontend/components/ui/button-group.tsx
new file mode 100644
index 0000000..09d4430
--- /dev/null
+++ b/frontend/components/ui/button-group.tsx
@@ -0,0 +1,83 @@
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+import { Separator } from '@/components/ui/separator'
+
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ '[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
+ vertical:
+ 'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
+ },
+ },
+ defaultVariants: {
+ orientation: 'horizontal',
+ },
+ },
+)
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ )
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'div'> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : 'div'
+
+ return (
+
+ )
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = 'vertical',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+}
diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx
new file mode 100644
index 0000000..f64632d
--- /dev/null
+++ b/frontend/components/ui/button.tsx
@@ -0,0 +1,60 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ destructive:
+ 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
+ secondary:
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ ghost:
+ 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
+ sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
+ lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
+ icon: 'size-9',
+ 'icon-sm': 'size-8',
+ 'icon-lg': 'size-10',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'button'> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : 'button'
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/frontend/components/ui/calendar.tsx b/frontend/components/ui/calendar.tsx
new file mode 100644
index 0000000..eaa373e
--- /dev/null
+++ b/frontend/components/ui/calendar.tsx
@@ -0,0 +1,213 @@
+'use client'
+
+import * as React from 'react'
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from 'lucide-react'
+import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
+
+import { cn } from '@/lib/utils'
+import { Button, buttonVariants } from '@/components/ui/button'
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = 'label',
+ buttonVariant = 'ghost',
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps['variant']
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className,
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString('default', { month: 'short' }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn('w-fit', defaultClassNames.root),
+ months: cn(
+ 'flex gap-4 flex-col md:flex-row relative',
+ defaultClassNames.months,
+ ),
+ month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
+ nav: cn(
+ 'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
+ defaultClassNames.nav,
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
+ defaultClassNames.button_previous,
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
+ defaultClassNames.button_next,
+ ),
+ month_caption: cn(
+ 'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
+ defaultClassNames.month_caption,
+ ),
+ dropdowns: cn(
+ 'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
+ defaultClassNames.dropdowns,
+ ),
+ dropdown_root: cn(
+ 'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
+ defaultClassNames.dropdown_root,
+ ),
+ dropdown: cn(
+ 'absolute bg-popover inset-0 opacity-0',
+ defaultClassNames.dropdown,
+ ),
+ caption_label: cn(
+ 'select-none font-medium',
+ captionLayout === 'label'
+ ? 'text-sm'
+ : 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
+ defaultClassNames.caption_label,
+ ),
+ table: 'w-full border-collapse',
+ weekdays: cn('flex', defaultClassNames.weekdays),
+ weekday: cn(
+ 'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
+ defaultClassNames.weekday,
+ ),
+ week: cn('flex w-full mt-2', defaultClassNames.week),
+ week_number_header: cn(
+ 'select-none w-(--cell-size)',
+ defaultClassNames.week_number_header,
+ ),
+ week_number: cn(
+ 'text-[0.8rem] select-none text-muted-foreground',
+ defaultClassNames.week_number,
+ ),
+ day: cn(
+ 'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
+ defaultClassNames.day,
+ ),
+ range_start: cn(
+ 'rounded-l-md bg-accent',
+ defaultClassNames.range_start,
+ ),
+ range_middle: cn('rounded-none', defaultClassNames.range_middle),
+ range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
+ today: cn(
+ 'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
+ defaultClassNames.today,
+ ),
+ outside: cn(
+ 'text-muted-foreground aria-selected:text-muted-foreground',
+ defaultClassNames.outside,
+ ),
+ disabled: cn(
+ 'text-muted-foreground opacity-50',
+ defaultClassNames.disabled,
+ ),
+ hidden: cn('invisible', defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === 'left') {
+ return (
+
+ )
+ }
+
+ if (orientation === 'right') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+ |
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+