feat: complete Phase 1 foundation scaffold
Some checks failed
CI / Lint (push) Has been cancelled
CI / TypeCheck (push) Has been cancelled
CI / Test (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/api/Dockerfile name:api]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/cms/Dockerfile name:cms]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/web/Dockerfile name:web]) (push) Has been cancelled
CI / Build (push) Has been cancelled
Deploy / Deploy (push) Has been cancelled
Some checks failed
CI / Lint (push) Has been cancelled
CI / TypeCheck (push) Has been cancelled
CI / Test (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/api/Dockerfile name:api]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/cms/Dockerfile name:cms]) (push) Has been cancelled
Deploy / Docker Build & Push (map[context:. dockerfile:apps/web/Dockerfile name:web]) (push) Has been cancelled
CI / Build (push) Has been cancelled
Deploy / Deploy (push) Has been cancelled
- Monorepo: Turborepo + pnpm workspaces with 7 apps/packages - Backend: NestJS scaffold with 9 modules (auth, tenant, curriculum, simulation, portfolio, ai-coach, analytics, classroom, gamification) - Frontend: React 18 + Vite + TailwindCSS + shadcn/ui with 10 pages, 14 UI primitives, 3 Zustand stores - Database: Prisma schema with 19 models, 13 enums, seed script, multi-tenant ready - Docker: Dev, production, and Portainer Compose files with Traefik reverse proxy - Configuration: .env.example (47 vars), Zod validation, frontend-safe env exposure - i18n: English + Greek locale files (10 files), ICU MessageFormat, react-i18next - Shared packages: @investplay/types, @investplay/ui, @investplay/i18n, @investplay/utils - CI/CD: GitHub Actions (lint, typecheck, test, build, deploy, PR checks) - Documentation: CONTEXT.md, ARCHITECTURE.md (10 Mermaid diagrams), API.md, LOCALIZATION.md, DEVELOPMENT-ROADMAP.md - Infrastructure: Dockerfiles (dev + prod), nginx configs, backup/healthcheck scripts - Security: JWT auth guards, role-based access, rate limiting, Helmet, CORS, PII sanitization
This commit is contained in:
20
apps/web/Dockerfile
Normal file
20
apps/web/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM node:20-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
|
||||
WORKDIR /app
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
|
||||
COPY apps/web/package.json apps/web/tsconfig.json apps/web/tsconfig.node.json apps/web/vite.config.ts ./apps/web/
|
||||
COPY packages/ ./packages/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY apps/web ./apps/web
|
||||
RUN pnpm --filter @investplay/web build
|
||||
|
||||
FROM nginx:alpine AS runner
|
||||
RUN apk add --no-cache wget
|
||||
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
|
||||
COPY apps/web/nginx.conf /etc/nginx/nginx.conf
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
10
apps/web/Dockerfile.dev
Normal file
10
apps/web/Dockerfile.dev
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM node:20-alpine
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
|
||||
WORKDIR /app
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
|
||||
COPY apps/web/package.json apps/web/tsconfig.json apps/web/vite.config.ts ./apps/web/
|
||||
COPY packages/ ./packages/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
EXPOSE 5173
|
||||
CMD ["pnpm", "--filter", "@investplay/web", "dev", "--host"]
|
||||
20
apps/web/index.html
Normal file
20
apps/web/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="InvestPlay — Learn financial literacy through interactive simulations and games" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<title>InvestPlay</title>
|
||||
</head>
|
||||
<body class="bg-surface-50 text-surface-900 dark:bg-surface-950 dark:text-surface-100 antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
125
apps/web/nginx.conf
Normal file
125
apps/web/nginx.conf
Normal file
@@ -0,0 +1,125 @@
|
||||
worker_processes auto;
|
||||
worker_rlimit_nofile 65535;
|
||||
|
||||
events {
|
||||
multi_accept on;
|
||||
worker_connections 65535;
|
||||
use epoll;
|
||||
}
|
||||
|
||||
http {
|
||||
charset utf-8;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
server_tokens off;
|
||||
log_not_found off;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 100M;
|
||||
|
||||
# MIME types
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/access.log combined buffer=512k flush=1m;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
# Timeouts
|
||||
keepalive_timeout 65;
|
||||
keepalive_requests 100;
|
||||
client_body_timeout 60;
|
||||
client_header_timeout 60;
|
||||
send_timeout 60;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
application/javascript
|
||||
application/json
|
||||
application/manifest+json
|
||||
application/xml
|
||||
image/svg+xml
|
||||
text/css
|
||||
text/javascript
|
||||
text/plain
|
||||
text/xml;
|
||||
|
||||
# Cache static assets with hashed filenames
|
||||
location ~* \.(?:css|js|map|json)$ {
|
||||
root /usr/share/nginx/html;
|
||||
expires 1y;
|
||||
access_log off;
|
||||
add_header Cache-Control "public, immutable, max-age=31536000";
|
||||
}
|
||||
|
||||
# Cache images, fonts, media
|
||||
location ~* \.(?:gif|jpe?g|png|webp|avif|ico|svg|woff2?|eot|ttf|otf)$ {
|
||||
root /usr/share/nginx/html;
|
||||
expires 1y;
|
||||
access_log off;
|
||||
add_header Cache-Control "public, immutable, max-age=31536000";
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
proxy_pass http://api:3001/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_read_timeout 90;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /graphql {
|
||||
proxy_pass http://api:3001/graphql;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 90;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Deny access to hidden files
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
# Deny access to sensitive paths
|
||||
location ~ ^/(\.env|\.git|node_modules|dist) {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
65
apps/web/package.json
Normal file
65
apps/web/package.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@investplay/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "InvestPlay React frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\" --fix",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"clean": "rimraf dist .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-router-dom": "^7.5.0",
|
||||
"react-i18next": "^15.4.0",
|
||||
"i18next": "^24.2.0",
|
||||
"zustand": "^5.0.0",
|
||||
"@tanstack/react-query": "^5.75.0",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"recharts": "^2.15.0",
|
||||
"zod": "^3.24.0",
|
||||
"lucide-react": "^0.490.0",
|
||||
"clsx": "^2.1.0",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-separator": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.3.0",
|
||||
"@vitejs/plugin-react": "^4.4.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.5.0",
|
||||
"vitest": "^3.1.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"typescript": "^5.8.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.37.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"rimraf": "^6.0.0"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
80
apps/web/src/App.tsx
Normal file
80
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { Suspense } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import i18nInstance from "@/lib/i18n";
|
||||
import { useUIStore } from "@/stores/ui.store";
|
||||
import { PublicLayout } from "@/layouts/PublicLayout";
|
||||
import { AuthenticatedLayout } from "@/layouts/AuthenticatedLayout";
|
||||
import { TeacherLayout } from "@/layouts/TeacherLayout";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { ErrorBoundary } from "@/components/shared/ErrorBoundary";
|
||||
|
||||
const HomePage = React.lazy(() => import("@/pages/HomePage").then((m) => ({ default: m.HomePage })));
|
||||
const DashboardPage = React.lazy(() => import("@/pages/DashboardPage").then((m) => ({ default: m.DashboardPage })));
|
||||
const LearnPage = React.lazy(() => import("@/pages/LearnPage").then((m) => ({ default: m.LearnPage })));
|
||||
const ModulePage = React.lazy(() => import("@/pages/ModulePage").then((m) => ({ default: m.ModulePage })));
|
||||
const LessonPage = React.lazy(() => import("@/pages/LessonPage").then((m) => ({ default: m.LessonPage })));
|
||||
const PlayPage = React.lazy(() => import("@/pages/PlayPage").then((m) => ({ default: m.PlayPage })));
|
||||
const ChallengePage = React.lazy(() => import("@/pages/PlayPage").then((m) => ({ default: m.PlayPage })));
|
||||
const PracticePage = React.lazy(() => import("@/pages/PracticePage").then((m) => ({ default: m.PracticePage })));
|
||||
const PortfolioPage = React.lazy(() => import("@/pages/PracticePage").then((m) => ({ default: m.PracticePage })));
|
||||
const SettingsPage = React.lazy(() => import("@/pages/SettingsPage").then((m) => ({ default: m.SettingsPage })));
|
||||
const ProfilePage = React.lazy(() => import("@/pages/ProfilePage").then((m) => ({ default: m.ProfilePage })));
|
||||
const NotFoundPage = React.lazy(() => import("@/pages/NotFoundPage").then((m) => ({ default: m.NotFoundPage })));
|
||||
|
||||
function PageLoader() {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route element={<PublicLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
</Route>
|
||||
<Route element={<AuthenticatedLayout />}>
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/learn" element={<LearnPage />} />
|
||||
<Route path="/learn/:moduleId" element={<ModulePage />} />
|
||||
<Route path="/learn/:moduleId/:lessonId" element={<LessonPage />} />
|
||||
<Route path="/play" element={<PlayPage />} />
|
||||
<Route path="/play/:challengeId" element={<ChallengePage />} />
|
||||
<Route path="/practice" element={<PracticePage />} />
|
||||
<Route path="/practice/portfolio" element={<PortfolioPage />} />
|
||||
<Route path="/teach" element={<TeacherLayout />}>
|
||||
<Route index element={<div className="text-muted-foreground">Teacher overview</div>} />
|
||||
<Route path="overview" element={<div className="text-muted-foreground">Overview</div>} />
|
||||
<Route path="classrooms" element={<div className="text-muted-foreground">Classrooms</div>} />
|
||||
<Route path="assignments" element={<div className="text-muted-foreground">Assignments</div>} />
|
||||
<Route path="reports" element={<div className="text-muted-foreground">Reports</div>} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { locale } = useUIStore();
|
||||
|
||||
if (i18nInstance.language !== locale) {
|
||||
void i18nInstance.changeLanguage(locale);
|
||||
}
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={i18nInstance}>
|
||||
<AppRoutes />
|
||||
</I18nextProvider>
|
||||
);
|
||||
}
|
||||
63
apps/web/src/components/curriculum/BlockRenderer.tsx
Normal file
63
apps/web/src/components/curriculum/BlockRenderer.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { BlockType, type LessonBlock } from "@investplay/types";
|
||||
import { HeroBlock } from "./HeroBlock";
|
||||
import { TextBlock } from "./TextBlock";
|
||||
import { QuizBlock } from "./QuizBlock";
|
||||
import { CalculatorBlock } from "./CalculatorBlock";
|
||||
|
||||
interface BlockRendererProps {
|
||||
block: LessonBlock;
|
||||
onQuizAnswer?: (blockId: string, answer: string | string[]) => void;
|
||||
quizResults?: Record<string, { correct: boolean; answer: string | string[] }>;
|
||||
}
|
||||
|
||||
export function BlockRenderer({ block, onQuizAnswer, quizResults }: BlockRendererProps) {
|
||||
switch (block.type) {
|
||||
case BlockType.HERO:
|
||||
return <HeroBlock content={block.content} />;
|
||||
|
||||
case BlockType.TEXT:
|
||||
case BlockType.SUMMARY:
|
||||
return <TextBlock content={block.content} />;
|
||||
|
||||
case BlockType.QUIZ:
|
||||
return (
|
||||
<QuizBlock
|
||||
content={block.content}
|
||||
blockId={block.id}
|
||||
result={quizResults?.[block.id]}
|
||||
onAnswer={onQuizAnswer}
|
||||
/>
|
||||
);
|
||||
|
||||
case BlockType.CALCULATOR:
|
||||
return <CalculatorBlock content={block.content} />;
|
||||
|
||||
case BlockType.REFLECTION:
|
||||
return (
|
||||
<div className="rounded-xl border border-accent/20 bg-accent/5 p-6">
|
||||
<h3 className="mb-2 font-semibold text-accent">Reflection</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{block.content.prompt as string ?? "Take a moment to reflect on what you've learned."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
case BlockType.TEACHER_NOTE:
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-6 dark:border-amber-800 dark:bg-amber-950">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
{block.content.note as string ?? "Teacher note"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="rounded-xl border border-border p-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Unsupported block type: {block.type}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
88
apps/web/src/components/curriculum/CalculatorBlock.tsx
Normal file
88
apps/web/src/components/curriculum/CalculatorBlock.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { formatCurrency } from "@investplay/ui";
|
||||
|
||||
interface CalculatorBlockProps {
|
||||
content: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type CalcType = "compound" | "loan" | "savings";
|
||||
|
||||
export function CalculatorBlock({ content }: CalculatorBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const calcType = (content.type as CalcType) ?? "compound";
|
||||
const [principal, setPrincipal] = useState("10000");
|
||||
const [rate, setRate] = useState("5");
|
||||
const [years, setYears] = useState("10");
|
||||
const [result, setResult] = useState<number | null>(null);
|
||||
|
||||
const calculate = () => {
|
||||
const p = parseFloat(principal);
|
||||
const r = parseFloat(rate) / 100;
|
||||
const n = parseFloat(years);
|
||||
|
||||
if (isNaN(p) || isNaN(r) || isNaN(n)) return;
|
||||
|
||||
let res = 0;
|
||||
switch (calcType) {
|
||||
case "compound":
|
||||
res = p * Math.pow(1 + r, n);
|
||||
break;
|
||||
case "loan": {
|
||||
const monthlyRate = r / 12;
|
||||
const payments = n * 12;
|
||||
res = (p * monthlyRate * Math.pow(1 + monthlyRate, payments)) /
|
||||
(Math.pow(1 + monthlyRate, payments) - 1);
|
||||
break;
|
||||
}
|
||||
case "savings":
|
||||
res = p * r * n + p;
|
||||
break;
|
||||
}
|
||||
setResult(Math.round(res * 100) / 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">
|
||||
{content.title as string ?? "Financial Calculator"}
|
||||
</h3>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Input
|
||||
label="Principal Amount"
|
||||
type="number"
|
||||
value={principal}
|
||||
onChange={(e) => setPrincipal(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Interest Rate (%)"
|
||||
type="number"
|
||||
value={rate}
|
||||
onChange={(e) => setRate(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Time Period (years)"
|
||||
type="number"
|
||||
value={years}
|
||||
onChange={(e) => setYears(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={calculate} className="mt-4">
|
||||
{t("actions.calculate") ?? "Calculate"}
|
||||
</Button>
|
||||
|
||||
{result !== null && (
|
||||
<div className="mt-4 rounded-lg bg-accent/10 p-4">
|
||||
<p className="text-sm text-muted-foreground">Result</p>
|
||||
<p className="text-2xl font-bold text-accent">
|
||||
{formatCurrency(result)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
apps/web/src/components/curriculum/HeroBlock.tsx
Normal file
33
apps/web/src/components/curriculum/HeroBlock.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
interface HeroBlockProps {
|
||||
content: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function HeroBlock({ content }: HeroBlockProps) {
|
||||
const title = content.title as string | undefined;
|
||||
const description = content.description as string | undefined;
|
||||
const imageUrl = content.imageUrl as string | undefined;
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-xl bg-gradient-to-br from-primary/10 via-accent/5 to-primary/5 p-8 md:p-12">
|
||||
<div className="relative z-10 max-w-2xl">
|
||||
{title && (
|
||||
<h1 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
{description && (
|
||||
<p className="mt-4 text-lg text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{imageUrl && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={title ?? ""}
|
||||
className="absolute right-0 top-0 h-full w-1/3 object-cover opacity-20"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
apps/web/src/components/curriculum/ProgressBar.tsx
Normal file
62
apps/web/src/components/curriculum/ProgressBar.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { cn } from "@investplay/ui";
|
||||
|
||||
interface ProgressBarProps {
|
||||
value: number;
|
||||
max?: number;
|
||||
showLabel?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
variant?: "default" | "accent" | "success";
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: "h-1.5",
|
||||
md: "h-2.5",
|
||||
lg: "h-4",
|
||||
};
|
||||
|
||||
const variantMap = {
|
||||
default: "bg-primary",
|
||||
accent: "bg-accent",
|
||||
success: "bg-emerald-500",
|
||||
};
|
||||
|
||||
export function ProgressBar({
|
||||
value,
|
||||
max = 100,
|
||||
showLabel = false,
|
||||
size = "md",
|
||||
className,
|
||||
variant = "default",
|
||||
}: ProgressBarProps) {
|
||||
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full overflow-hidden rounded-full bg-secondary",
|
||||
sizeMap[size],
|
||||
)}
|
||||
role="progressbar"
|
||||
aria-valuenow={value}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={max}
|
||||
aria-label={`Progress: ${Math.round(percentage)}%`}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-700 ease-out",
|
||||
variantMap[variant],
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
{showLabel && (
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
{Math.round(percentage)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
apps/web/src/components/curriculum/QuizBlock.tsx
Normal file
125
apps/web/src/components/curriculum/QuizBlock.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@investplay/ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CheckCircle, XCircle } from "lucide-react";
|
||||
|
||||
interface QuizBlockProps {
|
||||
content: Record<string, unknown>;
|
||||
blockId: string;
|
||||
result?: { correct: boolean; answer: string | string[] };
|
||||
onAnswer?: (blockId: string, answer: string | string[]) => void;
|
||||
}
|
||||
|
||||
interface QuestionData {
|
||||
question: string;
|
||||
options: string[];
|
||||
correctAnswer: string | string[];
|
||||
type: "multiple-choice" | "true-false";
|
||||
explanation?: string;
|
||||
}
|
||||
|
||||
export function QuizBlock({ content, blockId, result, onAnswer }: QuizBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const question = content.question as string;
|
||||
const options = content.options as string[] | undefined;
|
||||
const type = (content.type as string) ?? "multiple-choice";
|
||||
const explanation = content.explanation as string | undefined;
|
||||
|
||||
const isCorrect = result?.correct;
|
||||
const isAnswered = !!result || submitted;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selected || !onAnswer) return;
|
||||
onAnswer(blockId, selected);
|
||||
setSubmitted(true);
|
||||
};
|
||||
|
||||
const isCorrectAnswer = (option: string) => {
|
||||
if (!result) return false;
|
||||
const correct = result.answer;
|
||||
return Array.isArray(correct) ? correct.includes(option) : correct === option;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border p-6",
|
||||
isAnswered
|
||||
? isCorrect
|
||||
? "border-accent/30 bg-accent/5"
|
||||
: "border-destructive/30 bg-destructive/5"
|
||||
: "border-border",
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{type === "true-false" ? "True / False" : t("actions.submit")}
|
||||
</Badge>
|
||||
{isAnswered && (
|
||||
<Badge variant={isCorrect ? "success" : "destructive"}>
|
||||
{isCorrect ? t("actions.confirm") : t("actions.cancel")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-lg font-medium">{question}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{options?.map((option) => {
|
||||
const isSelected = selected === option;
|
||||
const isOptionCorrect = isCorrectAnswer(option);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => !isAnswered && setSelected(option)}
|
||||
disabled={isAnswered}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left text-sm transition-all",
|
||||
isAnswered && isOptionCorrect && "border-accent bg-accent/10 text-accent",
|
||||
isAnswered && isSelected && !isOptionCorrect && "border-destructive bg-destructive/10 text-destructive",
|
||||
!isAnswered && isSelected && "border-primary bg-primary/5",
|
||||
!isAnswered && !isSelected && "border-border hover:border-primary/50 hover:bg-secondary",
|
||||
isAnswered && "cursor-default",
|
||||
)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-current text-xs font-medium">
|
||||
{isAnswered && isOptionCorrect ? (
|
||||
<CheckCircle className="h-4 w-4 text-accent" />
|
||||
) : isAnswered && isSelected && !isOptionCorrect ? (
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
) : isSelected ? (
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
{option}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!isAnswered && (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!selected}
|
||||
className="mt-4"
|
||||
>
|
||||
{t("actions.submit")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isAnswered && explanation && (
|
||||
<div className="mt-4 rounded-lg bg-secondary p-4">
|
||||
<p className="text-sm font-medium mb-1">Explanation</p>
|
||||
<p className="text-sm text-muted-foreground">{explanation}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
apps/web/src/components/curriculum/TextBlock.tsx
Normal file
31
apps/web/src/components/curriculum/TextBlock.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
interface TextBlockProps {
|
||||
content: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function TextBlock({ content }: TextBlockProps) {
|
||||
const text = content.text as string | undefined;
|
||||
const variant = (content.variant as string) ?? "body";
|
||||
const level = (content.level as number) ?? 2;
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
const className: Record<string, string> = {
|
||||
h1: "text-3xl font-bold mt-8 mb-4",
|
||||
h2: "text-2xl font-semibold mt-6 mb-3",
|
||||
h3: "text-xl font-semibold mt-5 mb-2",
|
||||
body: "text-base leading-relaxed text-muted-foreground mb-4",
|
||||
small: "text-sm text-muted-foreground",
|
||||
quote:
|
||||
"border-l-4 border-primary pl-4 italic text-muted-foreground my-4",
|
||||
};
|
||||
|
||||
const Tag = variant === "body" || variant === "small" || variant === "quote"
|
||||
? "div"
|
||||
: (`h${level}` as keyof JSX.IntrinsicElements);
|
||||
|
||||
return (
|
||||
<Tag className={className[variant] ?? className.body}>
|
||||
{text}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
104
apps/web/src/components/curriculum/TrackCard.tsx
Normal file
104
apps/web/src/components/curriculum/TrackCard.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn, formatPercentage } from "@investplay/ui";
|
||||
import {
|
||||
BookOpen,
|
||||
CreditCard,
|
||||
TrendingUp,
|
||||
ShieldAlert,
|
||||
Globe,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { CurriculumTrack } from "@investplay/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
|
||||
interface TrackCardProps {
|
||||
track: CurriculumTrack;
|
||||
title: string;
|
||||
description: string;
|
||||
moduleCount: number;
|
||||
estimatedHours: number;
|
||||
progress: number;
|
||||
difficulty: "beginner" | "intermediate" | "advanced";
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const trackIcons: Record<CurriculumTrack, LucideIcon> = {
|
||||
SURVIVAL_BASICS: ShieldAlert,
|
||||
CREDIT_DEBT: CreditCard,
|
||||
WEALTH_BUILDER: TrendingUp,
|
||||
ANTI_HYPE: ShieldAlert,
|
||||
MACROECONOMICS: Globe,
|
||||
};
|
||||
|
||||
const trackGradients: Record<CurriculumTrack, string> = {
|
||||
SURVIVAL_BASICS: "from-blue-500/20 to-cyan-500/20",
|
||||
CREDIT_DEBT: "from-orange-500/20 to-red-500/20",
|
||||
WEALTH_BUILDER: "from-emerald-500/20 to-teal-500/20",
|
||||
ANTI_HYPE: "from-purple-500/20 to-pink-500/20",
|
||||
MACROECONOMICS: "from-amber-500/20 to-yellow-500/20",
|
||||
};
|
||||
|
||||
const difficultyColors: Record<string, string> = {
|
||||
beginner: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-100",
|
||||
intermediate: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-100",
|
||||
advanced: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-100",
|
||||
};
|
||||
|
||||
export function TrackCard({
|
||||
track,
|
||||
title,
|
||||
description,
|
||||
moduleCount,
|
||||
estimatedHours,
|
||||
progress,
|
||||
difficulty,
|
||||
onClick,
|
||||
}: TrackCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = trackIcons[track] ?? BookOpen;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden rounded-xl border border-border bg-card p-6 text-left transition-all hover:shadow-md hover:border-primary/50",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
aria-label={`${title} — ${progress}% complete`}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-gradient-to-br opacity-0 transition-opacity group-hover:opacity-100",
|
||||
trackGradients[track],
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<Badge className={cn("text-xs", difficultyColors[difficulty])}>
|
||||
{difficulty}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<h3 className="mb-1 text-lg font-semibold">{title}</h3>
|
||||
<p className="mb-4 text-sm text-muted-foreground line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground mb-3">
|
||||
<span>{moduleCount} modules</span>
|
||||
<span>~{estimatedHours}h</span>
|
||||
</div>
|
||||
|
||||
<ProgressBar value={progress} size="sm" variant="accent" />
|
||||
<span className="mt-1 block text-xs font-medium text-accent">
|
||||
{formatPercentage(progress)} complete
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
71
apps/web/src/components/gamification/BadgeCard.tsx
Normal file
71
apps/web/src/components/gamification/BadgeCard.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@investplay/ui";
|
||||
import type { Badge } from "@investplay/types";
|
||||
import { Lock, Award } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface BadgeCardProps {
|
||||
badge: Badge;
|
||||
earned: boolean;
|
||||
earnedDate?: string;
|
||||
}
|
||||
|
||||
export function BadgeCard({ badge, earned, earnedDate }: BadgeCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 rounded-xl border p-4 text-center transition-all",
|
||||
earned
|
||||
? "border-accent/30 bg-accent/5 hover:shadow-md"
|
||||
: "border-border bg-muted/30 opacity-60",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 w-14 items-center justify-center rounded-full",
|
||||
earned
|
||||
? "bg-gradient-to-br from-accent to-emerald-400 text-white"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{earned ? (
|
||||
<Award className="h-7 w-7" />
|
||||
) : (
|
||||
<Lock className="h-7 w-7" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
earned ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{badge.name}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[200px]">
|
||||
<p className="font-medium">{badge.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{badge.description}
|
||||
</p>
|
||||
{earnedDate && (
|
||||
<p className="text-xs text-accent mt-1">
|
||||
Earned {new Date(earnedDate).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
68
apps/web/src/components/gamification/BadgeGrid.tsx
Normal file
68
apps/web/src/components/gamification/BadgeGrid.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@investplay/ui";
|
||||
import type { Badge } from "@investplay/types";
|
||||
import { BadgeCard } from "./BadgeCard";
|
||||
|
||||
interface BadgeGridProps {
|
||||
badges: Badge[];
|
||||
earnedBadgeIds: Set<string>;
|
||||
className?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function BadgeGrid({
|
||||
badges,
|
||||
earnedBadgeIds,
|
||||
className,
|
||||
limit,
|
||||
}: BadgeGridProps) {
|
||||
const { t } = useTranslation();
|
||||
const displayBadges = limit ? badges.slice(0, limit) : badges;
|
||||
|
||||
const categories = [
|
||||
{ key: "milestone", labelKey: "gamification:badges.categories.milestone" },
|
||||
{ key: "skill", labelKey: "gamification:badges.categories.skill" },
|
||||
{ key: "behavior", labelKey: "gamification:badges.categories.behavior" },
|
||||
{ key: "special", labelKey: "gamification:badges.categories.special" },
|
||||
] as const;
|
||||
|
||||
if (!limit) {
|
||||
return (
|
||||
<div className={cn("space-y-8", className)}>
|
||||
{categories.map(({ key, labelKey }) => {
|
||||
const categoryBadges = badges.filter((b) => b.category === key);
|
||||
if (categoryBadges.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section key={key}>
|
||||
<h3 className="mb-3 text-sm font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{t(labelKey)}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{categoryBadges.map((badge) => (
|
||||
<BadgeCard
|
||||
key={badge.id}
|
||||
badge={badge}
|
||||
earned={earnedBadgeIds.has(badge.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4", className)}>
|
||||
{displayBadges.map((badge) => (
|
||||
<BadgeCard
|
||||
key={badge.id}
|
||||
badge={badge}
|
||||
earned={earnedBadgeIds.has(badge.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
apps/web/src/components/gamification/LeaderboardTable.tsx
Normal file
111
apps/web/src/components/gamification/LeaderboardTable.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn, formatXP } from "@investplay/ui";
|
||||
import type { LeaderboardEntry } from "@investplay/types";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TrendingUp, TrendingDown, Minus, Trophy } from "lucide-react";
|
||||
|
||||
interface LeaderboardTableProps {
|
||||
entries: LeaderboardEntry[];
|
||||
currentUserId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const rankStyles = [
|
||||
"text-yellow-500",
|
||||
"text-gray-400",
|
||||
"text-amber-600",
|
||||
];
|
||||
|
||||
const TrendIcon = ({ rank }: { rank: number }) => {
|
||||
if (rank <= 3) return null;
|
||||
if (rank <= 5) return <TrendingUp className="h-4 w-4 text-accent" />;
|
||||
if (rank >= 10) return <TrendingDown className="h-4 w-4 text-destructive" />;
|
||||
return <Minus className="h-4 w-4 text-muted-foreground" />;
|
||||
};
|
||||
|
||||
export function LeaderboardTable({
|
||||
entries,
|
||||
currentUserId,
|
||||
className,
|
||||
}: LeaderboardTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border p-8 text-center">
|
||||
<Trophy className="mb-2 h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("gamification:leaderboard.empty")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1", className)}>
|
||||
{entries.map((entry) => {
|
||||
const isCurrentUser = entry.userId === currentUserId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.userId}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-4 py-3 transition-colors",
|
||||
isCurrentUser
|
||||
? "bg-primary/5 ring-1 ring-primary/20"
|
||||
: "hover:bg-secondary",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-bold",
|
||||
entry.rank <= 3
|
||||
? rankStyles[entry.rank - 1]
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{entry.rank <= 3 ? (
|
||||
<Trophy className="h-5 w-5" />
|
||||
) : (
|
||||
entry.rank
|
||||
)}
|
||||
</span>
|
||||
|
||||
<Avatar className="h-8 w-8">
|
||||
{entry.avatarUrl && (
|
||||
<AvatarImage src={entry.avatarUrl} alt={entry.firstName} />
|
||||
)}
|
||||
<AvatarFallback>
|
||||
{entry.firstName[0]}{entry.lastName[0]}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{entry.firstName} {entry.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("gamification:level.label")} {entry.level} ·{" "}
|
||||
{entry.badges} {t("gamification:badges.title")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">
|
||||
{formatXP(entry.xp)}
|
||||
</span>
|
||||
<TrendIcon rank={entry.rank} />
|
||||
</div>
|
||||
|
||||
{isCurrentUser && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("gamification:leaderboard.yourRank")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
apps/web/src/components/gamification/StreakCounter.tsx
Normal file
45
apps/web/src/components/gamification/StreakCounter.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@investplay/ui";
|
||||
import { Flame } from "lucide-react";
|
||||
|
||||
interface StreakCounterProps {
|
||||
current: number;
|
||||
longest: number;
|
||||
className?: string;
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
export function StreakCounter({ current, longest, className, size = "md" }: StreakCounterProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasStreak = current > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
size === "sm" ? "text-sm" : "text-base",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full px-3 py-1 font-semibold",
|
||||
hasStreak
|
||||
? "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-100"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Flame
|
||||
className={cn(
|
||||
size === "sm" ? "h-4 w-4" : "h-5 w-5",
|
||||
hasStreak && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
<span>{current}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("gamification:streaks.longest", { count: longest })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
apps/web/src/components/gamification/XPBar.tsx
Normal file
51
apps/web/src/components/gamification/XPBar.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn, formatXP } from "@investplay/ui";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useGamificationStore, levelProgress } from "@/stores/gamification.store";
|
||||
|
||||
interface XPBarProps {
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export function XPBar({ className, showLabel = true, size = "md" }: XPBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { xp, level } = useGamificationStore();
|
||||
const progress = levelProgress(xp, level);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1", className)}>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
{showLabel && (
|
||||
<>
|
||||
<span className="font-medium">
|
||||
{t("gamification:level.current", { level })}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatXP(xp)} XP
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Progress
|
||||
value={progress * 100}
|
||||
className={cn(
|
||||
size === "sm" && "h-1.5",
|
||||
size === "md" && "h-2.5",
|
||||
size === "lg" && "h-3",
|
||||
)}
|
||||
indicatorClassName="bg-gradient-to-r from-accent to-emerald-400"
|
||||
/>
|
||||
{showLabel && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("gamification:level.progress", {
|
||||
current: level,
|
||||
progress: Math.round(progress * 100),
|
||||
next: level + 1,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
apps/web/src/components/layout/Breadcrumbs.tsx
Normal file
58
apps/web/src/components/layout/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useLocation, Link } from "react-router-dom";
|
||||
import { ChevronRight, Home } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const routeLabels: Record<string, string> = {
|
||||
dashboard: "nav.dashboard",
|
||||
learn: "nav.learn",
|
||||
play: "nav.play",
|
||||
practice: "nav.practice",
|
||||
teach: "nav.teach",
|
||||
settings: "nav.settings",
|
||||
profile: "nav.profile",
|
||||
};
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const { t } = useTranslation();
|
||||
const { pathname } = useLocation();
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="mb-4">
|
||||
<ol className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="hover:text-foreground transition-colors"
|
||||
aria-label="Home"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
</Link>
|
||||
</li>
|
||||
{segments.map((segment, index) => {
|
||||
const href = "/" + segments.slice(0, index + 1).join("/");
|
||||
const labelKey = routeLabels[segment];
|
||||
const label = labelKey ? t(labelKey) : decodeURIComponent(segment);
|
||||
const isLast = index === segments.length - 1;
|
||||
|
||||
return (
|
||||
<li key={segment} className="flex items-center gap-1">
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
{isLast ? (
|
||||
<span className="font-medium text-foreground" aria-current="page">
|
||||
{label}
|
||||
</span>
|
||||
) : (
|
||||
<Link to={href} className="hover:text-foreground transition-colors">
|
||||
{label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
54
apps/web/src/components/layout/MobileNav.tsx
Normal file
54
apps/web/src/components/layout/MobileNav.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { cn } from "@investplay/ui";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
BookOpen,
|
||||
Gamepad2,
|
||||
Brain,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const mobileItems = [
|
||||
{ to: "/dashboard", icon: LayoutDashboard, labelKey: "nav.dashboard" },
|
||||
{ to: "/learn", icon: BookOpen, labelKey: "nav.learn" },
|
||||
{ to: "/play", icon: Gamepad2, labelKey: "nav.play" },
|
||||
{ to: "/practice", icon: Brain, labelKey: "nav.practice" },
|
||||
{ to: "/more", icon: MoreHorizontal, labelKey: "nav.settings" },
|
||||
];
|
||||
|
||||
export function MobileNav() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="fixed bottom-0 left-0 right-0 z-50 flex h-16 border-t border-border bg-background md:hidden"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
{mobileItems.map((item) => {
|
||||
const isActive = item.to === "/more"
|
||||
? !["/dashboard", "/learn", "/play", "/practice"].some((p) =>
|
||||
location.pathname.startsWith(p),
|
||||
)
|
||||
: location.pathname.startsWith(item.to);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center justify-center gap-0.5 text-xs font-medium transition-colors",
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span>{t(item.labelKey)}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
122
apps/web/src/components/layout/Sidebar.tsx
Normal file
122
apps/web/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { cn } from "@investplay/ui";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
BookOpen,
|
||||
Gamepad2,
|
||||
Brain,
|
||||
GraduationCap,
|
||||
User,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UserRole } from "@investplay/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUIStore } from "@/stores/ui.store";
|
||||
|
||||
interface SidebarProps {
|
||||
role?: UserRole | null;
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ to: "/dashboard", icon: LayoutDashboard, labelKey: "nav.dashboard" },
|
||||
{ to: "/learn", icon: BookOpen, labelKey: "nav.learn" },
|
||||
{ to: "/play", icon: Gamepad2, labelKey: "nav.play" },
|
||||
{ to: "/practice", icon: Brain, labelKey: "nav.practice" },
|
||||
];
|
||||
|
||||
const teacherItems = [
|
||||
{ to: "/teach", icon: GraduationCap, labelKey: "nav.teach" },
|
||||
];
|
||||
|
||||
const bottomItems = [
|
||||
{ to: "/profile", icon: User, labelKey: "nav.profile" },
|
||||
{ to: "/settings", icon: Settings, labelKey: "nav.settings" },
|
||||
];
|
||||
|
||||
export function Sidebar({ role }: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { sidebarCollapsed, toggleSidebar } = useUIStore();
|
||||
const location = useLocation();
|
||||
|
||||
const isTeacher =
|
||||
role === "TEACHER" || role === "TENANT_ADMIN" || role === "PLATFORM_ADMIN";
|
||||
|
||||
const linkClasses = (to: string) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
location.pathname.startsWith(to)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-secondary hover:text-foreground",
|
||||
sidebarCollapsed && "justify-center px-2",
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed left-0 top-0 z-40 flex h-screen flex-col border-r border-border bg-card transition-all duration-300",
|
||||
sidebarCollapsed ? "w-16" : "w-64",
|
||||
)}
|
||||
aria-label={t("nav.dashboard")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 items-center border-b border-border",
|
||||
sidebarCollapsed ? "justify-center" : "justify-between px-4",
|
||||
)}
|
||||
>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="text-lg font-bold text-primary">InvestPlay</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 p-3">
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} className={linkClasses(item.to)}>
|
||||
<item.icon className="h-5 w-5 shrink-0" />
|
||||
{!sidebarCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{isTeacher && (
|
||||
<>
|
||||
<div className="my-2 border-t border-border" />
|
||||
{teacherItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={linkClasses(item.to)}
|
||||
>
|
||||
<item.icon className="h-5 w-5 shrink-0" />
|
||||
{!sidebarCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border p-3 space-y-1">
|
||||
{bottomItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} className={linkClasses(item.to)}>
|
||||
<item.icon className="h-5 w-5 shrink-0" />
|
||||
{!sidebarCollapsed && <span>{t(item.labelKey)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
117
apps/web/src/components/layout/TopBar.tsx
Normal file
117
apps/web/src/components/layout/TopBar.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bell, Search, Moon, Sun, Monitor } from "lucide-react";
|
||||
import { cn, formatXP } from "@investplay/ui";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { LanguageSwitcher } from "@/components/shared/LanguageSwitcher";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useUIStore, type ThemeMode } from "@/stores/ui.store";
|
||||
import { useGamificationStore } from "@/stores/gamification.store";
|
||||
|
||||
export function TopBar() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { theme, setTheme } = useUIStore();
|
||||
const { xp, level } = useGamificationStore();
|
||||
|
||||
const userInitials =
|
||||
user?.firstName && user?.lastName
|
||||
? `${user.firstName[0]}${user.lastName[0]}`
|
||||
: "U";
|
||||
|
||||
const themeIcon = {
|
||||
light: Sun,
|
||||
dark: Moon,
|
||||
system: Monitor,
|
||||
};
|
||||
|
||||
const nextTheme: Record<ThemeMode, ThemeMode> = {
|
||||
light: "dark",
|
||||
dark: "system",
|
||||
system: "light",
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4 lg:px-6">
|
||||
<div className="hidden md:flex flex-1 items-center gap-2">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9 h-9"
|
||||
placeholder={t("actions.search")}
|
||||
aria-label={t("actions.search")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-end gap-3">
|
||||
{user && (
|
||||
<span className="hidden sm:inline-flex items-center gap-1 text-sm font-medium text-muted-foreground">
|
||||
<span className="text-accent font-semibold">{formatXP(xp)}</span>
|
||||
<span className="text-xs">XP</span>
|
||||
<span className="mx-1">·</span>
|
||||
<span>
|
||||
{t("gamification:level.label")} {level}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<LanguageSwitcher />
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(nextTheme[theme])}
|
||||
aria-label={`Switch to ${nextTheme[theme]} theme`}
|
||||
className="hidden sm:inline-flex"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Moon className="h-5 w-5" />
|
||||
) : theme === "system" ? (
|
||||
<Monitor className="h-5 w-5" />
|
||||
) : (
|
||||
<Sun className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Notifications"
|
||||
className="relative"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground">
|
||||
3
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full" aria-label="User menu">
|
||||
<Avatar className="h-8 w-8">
|
||||
{user?.avatarUrl && <AvatarImage src={user.avatarUrl} alt={user.firstName} />}
|
||||
<AvatarFallback>{userInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="/profile">{t("nav.profile")}</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="/settings">{t("nav.settings")}</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
62
apps/web/src/components/shared/ConfirmDialog.tsx
Normal file
62
apps/web/src/components/shared/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: "default" | "destructive";
|
||||
onConfirm: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
variant = "default",
|
||||
onConfirm,
|
||||
isLoading,
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{cancelLabel ?? t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant === "destructive" ? "destructive" : "default"}
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? t("actions.continue") : confirmLabel ?? t("actions.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
45
apps/web/src/components/shared/EmptyState.tsx
Normal file
45
apps/web/src/components/shared/EmptyState.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { cn } from "@investplay/ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Inbox } from "lucide-react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center rounded-xl border border-dashed border-border p-12 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 text-muted-foreground">
|
||||
{icon ?? <Inbox className="h-12 w-12" />}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
{description && (
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{actionLabel && onAction && (
|
||||
<Button onClick={onAction} className="mt-4">
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
apps/web/src/components/shared/ErrorBoundary.tsx
Normal file
71
apps/web/src/components/shared/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
console.error("ErrorBoundary caught:", error, errorInfo);
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
return <ErrorFallback onRetry={this.handleRetry} />;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function ErrorFallback({ onRetry }: { onRetry: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center rounded-xl border border-destructive/20 bg-destructive/5 p-12 text-center">
|
||||
<AlertTriangle className="mb-4 h-12 w-12 text-destructive" />
|
||||
<h2 className="text-xl font-semibold">{t("errors.generic")}</h2>
|
||||
<p className="mt-2 max-w-md text-sm text-muted-foreground">
|
||||
{t("errors.serverError")}
|
||||
</p>
|
||||
<Button onClick={onRetry} variant="outline" className="mt-6">
|
||||
{t("actions.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useErrorHandler() {
|
||||
const [error, setError] = React.useState<Error | null>(null);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return setError;
|
||||
}
|
||||
55
apps/web/src/components/shared/LanguageSwitcher.tsx
Normal file
55
apps/web/src/components/shared/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Globe } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useUIStore } from "@/stores/ui.store";
|
||||
|
||||
const languages = [
|
||||
{ code: "en", label: "language.en", flag: "🇬🇧" },
|
||||
{ code: "el", label: "language.el", flag: "🇬🇷" },
|
||||
];
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { setLocale } = useUIStore();
|
||||
|
||||
const handleChange = (code: string) => {
|
||||
void i18n.changeLanguage(code);
|
||||
setLocale(code);
|
||||
};
|
||||
|
||||
const currentLang = languages.find((l) => l.code === i18n.language) ?? languages[0];
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
aria-label={t("common:language.en")}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{currentLang.flag}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{languages.map((lang) => (
|
||||
<DropdownMenuItem
|
||||
key={lang.code}
|
||||
onClick={() => handleChange(lang.code)}
|
||||
className={i18n.language === lang.code ? "bg-secondary" : ""}
|
||||
>
|
||||
<span className="mr-2">{lang.flag}</span>
|
||||
{t(lang.label)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
33
apps/web/src/components/shared/LoadingSpinner.tsx
Normal file
33
apps/web/src/components/shared/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { cn } from "@investplay/ui";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: "h-4 w-4 border-2",
|
||||
md: "h-8 w-8 border-3",
|
||||
lg: "h-12 w-12 border-4",
|
||||
};
|
||||
|
||||
export function LoadingSpinner({ size = "md", className, label }: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={label ?? "Loading"}
|
||||
className={cn("flex flex-col items-center justify-center gap-2", className)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"animate-spin rounded-full border-primary border-t-transparent",
|
||||
sizeMap[size],
|
||||
)}
|
||||
/>
|
||||
{label && (
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
apps/web/src/components/shared/ThemeToggle.tsx
Normal file
50
apps/web/src/components/shared/ThemeToggle.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sun, Moon, Monitor } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useUIStore, type ThemeMode } from "@/stores/ui.store";
|
||||
|
||||
const themes: { mode: ThemeMode; icon: typeof Sun; labelKey: string }[] = [
|
||||
{ mode: "light", icon: Sun, labelKey: "theme.light" },
|
||||
{ mode: "dark", icon: Moon, labelKey: "theme.dark" },
|
||||
{ mode: "system", icon: Monitor, labelKey: "theme.system" },
|
||||
];
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { t } = useTranslation();
|
||||
const { theme, setTheme } = useUIStore();
|
||||
|
||||
const currentIcon = themes.find((th) => th.mode === theme)?.icon ?? Sun;
|
||||
const Icon = currentIcon;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("theme." + theme)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{themes.map(({ mode, icon: ThemeIcon, labelKey }) => (
|
||||
<DropdownMenuItem
|
||||
key={mode}
|
||||
onClick={() => setTheme(mode)}
|
||||
className={theme === mode ? "bg-secondary" : ""}
|
||||
>
|
||||
<ThemeIcon className="mr-2 h-4 w-4" />
|
||||
{t(labelKey)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
47
apps/web/src/components/ui/avatar.tsx
Normal file
47
apps/web/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import { cn } from "@investplay/ui";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
33
apps/web/src/components/ui/badge.tsx
Normal file
33
apps/web/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@investplay/ui";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground",
|
||||
outline: "text-foreground",
|
||||
success: "border-transparent bg-accent text-accent-foreground",
|
||||
warning:
|
||||
"border-transparent bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
52
apps/web/src/components/ui/button.tsx
Normal file
52
apps/web/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@investplay/ui";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-border bg-transparent hover:bg-secondary hover:text-secondary-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-secondary hover:text-secondary-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
sm: "h-9 px-3 text-xs",
|
||||
md: "h-10 px-4 py-2",
|
||||
lg: "h-11 px-8 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "md",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
69
apps/web/src/components/ui/card.tsx
Normal file
69
apps/web/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@investplay/ui";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-card text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("text-xl font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
84
apps/web/src/components/ui/dialog.tsx
Normal file
84
apps/web/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { cn } from "@investplay/ui";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-fade-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-fade-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
85
apps/web/src/components/ui/dropdown-menu.tsx
Normal file
85
apps/web/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { cn } from "@investplay/ui";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-md animate-fade-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-lg px-2 py-1.5 text-sm outline-none transition-colors focus:bg-secondary focus:text-secondary-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
51
apps/web/src/components/ui/input.tsx
Normal file
51
apps/web/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@investplay/ui";
|
||||
import { Label } from "./label";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, label, error, helperText, id, ...props }, ref) => {
|
||||
const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<Label htmlFor={inputId} error={!!error}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
error && "border-destructive focus-visible:ring-destructive",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? `${inputId}-error` : helperText ? `${inputId}-helper` : undefined}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p id={`${inputId}-error`} className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={`${inputId}-helper`} className="text-sm text-muted-foreground">
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
33
apps/web/src/components/ui/label.tsx
Normal file
33
apps/web/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@investplay/ui";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
{
|
||||
variants: {
|
||||
error: {
|
||||
true: "text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
error: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, error, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants({ error }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
30
apps/web/src/components/ui/progress.tsx
Normal file
30
apps/web/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
import { cn } from "@investplay/ui";
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
|
||||
indicatorClassName?: string;
|
||||
}
|
||||
>(({ className, value, indicatorClassName, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn(
|
||||
"h-full w-full flex-1 rounded-full bg-primary transition-all duration-500 ease-out",
|
||||
indicatorClassName,
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
23
apps/web/src/components/ui/separator.tsx
Normal file
23
apps/web/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { cn } from "@investplay/ui";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
16
apps/web/src/components/ui/skeleton.tsx
Normal file
16
apps/web/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@investplay/ui";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
52
apps/web/src/components/ui/tabs.tsx
Normal file
52
apps/web/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "@investplay/ui";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-lg bg-secondary p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
119
apps/web/src/components/ui/toast.tsx
Normal file
119
apps/web/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as React from "react";
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@investplay/ui";
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed bottom-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:max-w-[420px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-xl border p-4 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-slide-up data-[state=closed]:animate-fade-in data-[swipe=end]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
success: "border-accent bg-accent/10 text-foreground",
|
||||
destructive: "destructive group border-destructive bg-destructive/10 text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100",
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
interface UseToastReturn {
|
||||
toast: (props: { title?: string; description?: string; variant?: "default" | "success" | "destructive" }) => void;
|
||||
}
|
||||
|
||||
function useToast(): UseToastReturn {
|
||||
const toast = React.useCallback(
|
||||
({ title, description, variant = "default" }: { title?: string; description?: string; variant?: "default" | "success" | "destructive" }) => {
|
||||
const event = new CustomEvent("investplay-toast", {
|
||||
detail: { title, description, variant },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { toast };
|
||||
}
|
||||
|
||||
export {
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
useToast,
|
||||
};
|
||||
25
apps/web/src/components/ui/tooltip.tsx
Normal file
25
apps/web/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@investplay/ui";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-lg border border-border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-fade-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
68
apps/web/src/hooks/useAICoach.ts
Normal file
68
apps/web/src/hooks/useAICoach.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { getSocket } from "@/lib/socket";
|
||||
import type { AICoachMessage } from "@investplay/types";
|
||||
|
||||
interface UseAICoachOptions {
|
||||
sessionId: string;
|
||||
lessonId?: string;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function useAICoach({ sessionId, lessonId, locale = "en" }: UseAICoachOptions) {
|
||||
const [messages, setMessages] = useState<AICoachMessage[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const streamRef = useRef<string>("");
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(content: string) => {
|
||||
const socket = getSocket();
|
||||
if (!socket || isStreaming) return;
|
||||
|
||||
const userMessage: AICoachMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
sessionId,
|
||||
role: "user",
|
||||
content,
|
||||
metadata: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setIsStreaming(true);
|
||||
setError(null);
|
||||
streamRef.current = "";
|
||||
|
||||
socket.emit(
|
||||
"ai:message",
|
||||
{
|
||||
sessionId,
|
||||
content,
|
||||
lessonId,
|
||||
locale,
|
||||
},
|
||||
(response: { error?: string }) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
[sessionId, lessonId, locale, isStreaming],
|
||||
);
|
||||
|
||||
const clearChat = useCallback(() => {
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
streamRef.current = "";
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isStreaming,
|
||||
error,
|
||||
sendMessage,
|
||||
clearChat,
|
||||
};
|
||||
}
|
||||
53
apps/web/src/hooks/useAuth.ts
Normal file
53
apps/web/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAuthStore } from "@/stores/auth.store";
|
||||
import { setAuthTokens, clearAuthTokens } from "@/lib/api";
|
||||
import { setGraphQLToken } from "@/lib/graphql";
|
||||
import { disconnectSocket } from "@/lib/socket";
|
||||
import type { User } from "@investplay/types";
|
||||
|
||||
export function useAuth() {
|
||||
const store = useAuthStore();
|
||||
|
||||
const login = useCallback(
|
||||
(user: User, accessToken: string, refreshToken: string) => {
|
||||
setAuthTokens(accessToken, refreshToken);
|
||||
setGraphQLToken(accessToken);
|
||||
store.login(user, accessToken, refreshToken);
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
(user: User, accessToken: string, refreshToken: string) => {
|
||||
setAuthTokens(accessToken, refreshToken);
|
||||
setGraphQLToken(accessToken);
|
||||
store.register(user, accessToken, refreshToken);
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearAuthTokens();
|
||||
setGraphQLToken(null);
|
||||
disconnectSocket();
|
||||
store.logout();
|
||||
}, [store]);
|
||||
|
||||
const updateUser = useCallback(
|
||||
(partial: Partial<User>) => {
|
||||
store.updateUser(partial);
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
return {
|
||||
user: store.user,
|
||||
accessToken: store.accessToken,
|
||||
isAuthenticated: store.isAuthenticated,
|
||||
isLoading: store.isLoading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
updateUser,
|
||||
};
|
||||
}
|
||||
29
apps/web/src/hooks/useLocalizedContent.ts
Normal file
29
apps/web/src/hooks/useLocalizedContent.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface LocalizedContent<T> {
|
||||
data: T;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function useLocalizedContent<T>(
|
||||
key: string,
|
||||
path: string,
|
||||
options?: Omit<UseQueryOptions<LocalizedContent<T>>, "queryKey" | "queryFn">,
|
||||
) {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return useQuery<LocalizedContent<T>>({
|
||||
queryKey: [key, path, i18n.language],
|
||||
queryFn: async () => {
|
||||
const response = await api<T>(path, {
|
||||
headers: {
|
||||
"Accept-Language": i18n.language,
|
||||
},
|
||||
});
|
||||
return { data: response, locale: i18n.language };
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
27
apps/web/src/hooks/useMediaQuery.ts
Normal file
27
apps/web/src/hooks/useMediaQuery.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return window.matchMedia(query).matches;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export const useIsMobile = () => useMediaQuery("(max-width: 767px)");
|
||||
export const useIsTablet = () => useMediaQuery("(min-width: 768px) and (max-width: 1023px)");
|
||||
export const useIsDesktop = () => useMediaQuery("(min-width: 1024px)");
|
||||
73
apps/web/src/hooks/useSimulation.ts
Normal file
73
apps/web/src/hooks/useSimulation.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { getSocket, connectSocket, disconnectSocket } from "@/lib/socket";
|
||||
import type { SimulationSession, SimulationType } from "@investplay/types";
|
||||
|
||||
interface UseSimulationOptions {
|
||||
simulationType: SimulationType;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function useSimulation({ simulationType, token }: UseSimulationOptions) {
|
||||
const [session, setSession] = useState<SimulationSession | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const tickRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = connectSocket(token);
|
||||
|
||||
socket.on("connect", () => setIsConnected(true));
|
||||
socket.on("disconnect", () => setIsConnected(false));
|
||||
|
||||
socket.emit("simulation:join", { simulationType });
|
||||
|
||||
socket.on("simulation:state", (data: SimulationSession) => {
|
||||
setSession(data);
|
||||
});
|
||||
|
||||
socket.on("simulation:tick", (data: SimulationSession) => {
|
||||
tickRef.current = data.ticks.length;
|
||||
setSession(data);
|
||||
});
|
||||
|
||||
socket.on("simulation:error", (err: { message: string }) => {
|
||||
setError(err.message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off("simulation:state");
|
||||
socket.off("simulation:tick");
|
||||
socket.off("simulation:error");
|
||||
socket.emit("simulation:leave", { sessionId: session?.id });
|
||||
disconnectSocket();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [simulationType, token]);
|
||||
|
||||
const makeDecision = useCallback(
|
||||
(optionId: string) => {
|
||||
const socket = getSocket();
|
||||
if (!socket || !session) return;
|
||||
socket.emit("simulation:decide", {
|
||||
sessionId: session.id,
|
||||
optionId,
|
||||
currentTick: tickRef.current,
|
||||
});
|
||||
},
|
||||
[session],
|
||||
);
|
||||
|
||||
const completeSimulation = useCallback(() => {
|
||||
const socket = getSocket();
|
||||
if (!socket || !session) return;
|
||||
socket.emit("simulation:complete", { sessionId: session.id });
|
||||
}, [session]);
|
||||
|
||||
return {
|
||||
session,
|
||||
isConnected,
|
||||
error,
|
||||
makeDecision,
|
||||
completeSimulation,
|
||||
};
|
||||
}
|
||||
48
apps/web/src/layouts/AuthenticatedLayout.tsx
Normal file
48
apps/web/src/layouts/AuthenticatedLayout.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Outlet, Navigate } from "react-router-dom";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useUIStore } from "@/stores/ui.store";
|
||||
import { useIsMobile } from "@/hooks/useMediaQuery";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { TopBar } from "@/components/layout/TopBar";
|
||||
import { MobileNav } from "@/components/layout/MobileNav";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { cn } from "@investplay/ui";
|
||||
|
||||
export function AuthenticatedLayout() {
|
||||
const { isAuthenticated, isLoading, user } = useAuth();
|
||||
const { sidebarCollapsed } = useUIStore();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<LoadingSpinner size="lg" label="Loading..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
{!isMobile && <Sidebar role={user?.role} />}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 flex-col transition-all duration-300",
|
||||
!isMobile && (sidebarCollapsed ? "ml-16" : "ml-64"),
|
||||
isMobile && "pb-16",
|
||||
)}
|
||||
>
|
||||
<TopBar />
|
||||
<main className="flex-1 p-4 lg:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{isMobile && <MobileNav />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
apps/web/src/layouts/PublicLayout.tsx
Normal file
113
apps/web/src/layouts/PublicLayout.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Link, Outlet } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LanguageSwitcher } from "@/components/shared/LanguageSwitcher";
|
||||
import { ThemeToggle } from "@/components/shared/ThemeToggle";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const footerLinks = [
|
||||
{ href: "/about", label: "About" },
|
||||
{ href: "/privacy", label: "Privacy" },
|
||||
{ href: "/terms", label: "Terms" },
|
||||
{ href: "/contact", label: "Contact" },
|
||||
];
|
||||
|
||||
export function PublicLayout() {
|
||||
const { t } = useTranslation();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<Link to="/" className="text-xl font-bold text-primary">
|
||||
InvestPlay
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
<Link to="/learn" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
{t("nav.learn")}
|
||||
</Link>
|
||||
<Link to="/play" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
{t("nav.play")}
|
||||
</Link>
|
||||
<Link to="/practice" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
{t("nav.practice")}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link to="/login">{t("auth.login")}</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/register">{t("auth.register")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
className="md:hidden p-2"
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mobileOpen && (
|
||||
<div className="border-t border-border md:hidden">
|
||||
<nav className="flex flex-col gap-2 p-4">
|
||||
<Link to="/learn" className="py-2 text-sm" onClick={() => setMobileOpen(false)}>
|
||||
{t("nav.learn")}
|
||||
</Link>
|
||||
<Link to="/play" className="py-2 text-sm" onClick={() => setMobileOpen(false)}>
|
||||
{t("nav.play")}
|
||||
</Link>
|
||||
<Link to="/practice" className="py-2 text-sm" onClick={() => setMobileOpen(false)}>
|
||||
{t("nav.practice")}
|
||||
</Link>
|
||||
<div className="flex gap-2 pt-2 border-t border-border">
|
||||
<Button variant="ghost" asChild className="flex-1">
|
||||
<Link to="/login" onClick={() => setMobileOpen(false)}>{t("auth.login")}</Link>
|
||||
</Button>
|
||||
<Button asChild className="flex-1">
|
||||
<Link to="/register" onClick={() => setMobileOpen(false)}>{t("auth.register")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-border bg-card">
|
||||
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
|
||||
<span className="text-sm font-semibold text-primary">InvestPlay</span>
|
||||
<nav className="flex flex-wrap justify-center gap-6">
|
||||
{footerLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
to={link.href}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
© {new Date().getFullYear()} InvestPlay. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
apps/web/src/layouts/TeacherLayout.tsx
Normal file
59
apps/web/src/layouts/TeacherLayout.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { cn } from "@investplay/ui";
|
||||
import { Breadcrumbs } from "@/components/layout/Breadcrumbs";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
|
||||
const teacherTabs = [
|
||||
{ value: "overview", labelKey: "classroom:dashboard.overview" },
|
||||
{ value: "classrooms", labelKey: "classroom:cohorts.title" },
|
||||
{ value: "assignments", labelKey: "classroom:assignments.title" },
|
||||
{ value: "reports", labelKey: "classroom:reports.title" },
|
||||
];
|
||||
|
||||
export function TeacherLayout() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const isTeacher =
|
||||
user?.role === "TEACHER" || user?.role === "TENANT_ADMIN" || user?.role === "PLATFORM_ADMIN";
|
||||
|
||||
const currentTab = location.pathname.split("/").filter(Boolean)[1] ?? "overview";
|
||||
|
||||
if (!isTeacher) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs />
|
||||
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">{t("classroom:dashboard.title")}</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{t("classroom:dashboard.overview")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onValueChange={(value) => navigate(`/teach/${value}`)}
|
||||
className="mb-6"
|
||||
>
|
||||
<TabsList>
|
||||
{teacherTabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{t(tab.labelKey)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
apps/web/src/lib/api.ts
Normal file
140
apps/web/src/lib/api.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { env } from "./env";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
public code?: string,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super("Network error. Please check your connection.");
|
||||
this.name = "NetworkError";
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
let accessToken: string | null = null;
|
||||
let refreshToken: string | null = null;
|
||||
let onTokenRefresh: ((tokens: { accessToken: string; refreshToken: string }) => void) | null = null;
|
||||
|
||||
export function setAuthTokens(access: string, refresh: string): void {
|
||||
accessToken = access;
|
||||
refreshToken = refresh;
|
||||
}
|
||||
|
||||
export function clearAuthTokens(): void {
|
||||
accessToken = null;
|
||||
refreshToken = null;
|
||||
}
|
||||
|
||||
export function setTokenRefreshHandler(
|
||||
handler: (tokens: { accessToken: string; refreshToken: string }) => void,
|
||||
): void {
|
||||
onTokenRefresh = handler;
|
||||
}
|
||||
|
||||
interface RequestConfig extends Omit<RequestInit, "body"> {
|
||||
params?: Record<string, string | undefined>;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export async function api<T = unknown>(
|
||||
path: string,
|
||||
config: RequestConfig = {},
|
||||
): Promise<T> {
|
||||
const { params, body, headers: customHeaders, ...rest } = config;
|
||||
|
||||
let url = `${env.API_URL}${path}`;
|
||||
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined) {
|
||||
searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
const qs = searchParams.toString();
|
||||
if (qs) url += `?${qs}`;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...customHeaders,
|
||||
} as Record<string, string>;
|
||||
|
||||
if (accessToken) {
|
||||
headers["Authorization"] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...rest,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (response.status === 401 && refreshToken && onTokenRefresh) {
|
||||
const refreshed = await attemptTokenRefresh();
|
||||
if (refreshed) {
|
||||
headers["Authorization"] = `Bearer ${accessToken}`;
|
||||
const retryResponse = await fetch(url, {
|
||||
...rest,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!retryResponse.ok) {
|
||||
throw await parseError(retryResponse);
|
||||
}
|
||||
return retryResponse.json() as Promise<T>;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw await parseError(response);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function parseError(response: Response): Promise<ApiError> {
|
||||
try {
|
||||
const body = await response.json();
|
||||
return new ApiError(
|
||||
response.status,
|
||||
body.message ?? response.statusText,
|
||||
body.code,
|
||||
body.details,
|
||||
);
|
||||
} catch {
|
||||
return new ApiError(response.status, response.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
async function attemptTokenRefresh(): Promise<boolean> {
|
||||
if (!refreshToken || !onTokenRefresh) return false;
|
||||
try {
|
||||
const response = await fetch(`${env.API_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const data = await response.json();
|
||||
accessToken = data.accessToken;
|
||||
refreshToken = data.refreshToken;
|
||||
onTokenRefresh({ accessToken: data.accessToken, refreshToken: data.refreshToken });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
35
apps/web/src/lib/env.ts
Normal file
35
apps/web/src/lib/env.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Frontend-safe environment variables.
|
||||
*
|
||||
* ⚠️ Only variables prefixed with `VITE_` are available at runtime in the
|
||||
* Vite-based web app. Never expose secrets here.
|
||||
*/
|
||||
|
||||
function getString(key: string, fallback: string): string {
|
||||
return (import.meta.env[key] as string | undefined) ?? fallback;
|
||||
}
|
||||
|
||||
function getBoolean(key: string, fallback: boolean): boolean {
|
||||
const raw = import.meta.env[key] as string | undefined;
|
||||
if (raw === undefined) return fallback;
|
||||
return raw === "true" || raw === "1" || raw === "yes";
|
||||
}
|
||||
|
||||
export const env = {
|
||||
/** Base URL for the API server (e.g. "http://localhost:3001") */
|
||||
API_URL: getString("VITE_API_URL", "http://localhost:3001"),
|
||||
|
||||
/** WebSocket URL for real-time features (defaults to API_URL) */
|
||||
WS_URL: getString("VITE_WS_URL", "http://localhost:3001"),
|
||||
|
||||
/** Public-facing URL of the frontend itself */
|
||||
APP_URL: getString("VITE_APP_URL", "http://localhost:5173"),
|
||||
|
||||
/** Default UI locale */
|
||||
DEFAULT_LOCALE: getString("VITE_DEFAULT_LOCALE", "en"),
|
||||
|
||||
/** Whether Clerk authentication is enabled on the frontend */
|
||||
CLERK_ENABLED: getBoolean("VITE_CLERK_ENABLED", false),
|
||||
} as const;
|
||||
|
||||
export type WebEnv = typeof env;
|
||||
64
apps/web/src/lib/graphql.ts
Normal file
64
apps/web/src/lib/graphql.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ApolloClient, InMemoryCache, ApolloLink, HttpLink, from } from "@apollo/client";
|
||||
import { setContext } from "@apollo/client/link/context";
|
||||
import { onError } from "@apollo/client/link/error";
|
||||
import { env } from "./env";
|
||||
|
||||
let accessToken: string | null = null;
|
||||
|
||||
export function setGraphQLToken(token: string | null): void {
|
||||
accessToken = token;
|
||||
}
|
||||
|
||||
const httpLink = new HttpLink({
|
||||
uri: `${env.API_URL}/graphql`,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
const authLink = setContext((_, { headers }) => {
|
||||
const authHeaders: Record<string, string> = {};
|
||||
if (accessToken) {
|
||||
authHeaders["Authorization"] = `Bearer ${accessToken}`;
|
||||
}
|
||||
return {
|
||||
headers: {
|
||||
...headers,
|
||||
...authHeaders,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
|
||||
if (graphQLErrors) {
|
||||
for (const err of graphQLErrors) {
|
||||
if (err.extensions?.code === "UNAUTHENTICATED") {
|
||||
return forward(operation);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (networkError) {
|
||||
console.error("GraphQL network error:", networkError.message);
|
||||
}
|
||||
});
|
||||
|
||||
export const apolloClient = new ApolloClient({
|
||||
link: from([errorLink, authLink, httpLink]),
|
||||
cache: new InMemoryCache({
|
||||
typePolicies: {
|
||||
User: {
|
||||
keyFields: ["id"],
|
||||
},
|
||||
Lesson: {
|
||||
keyFields: ["id"],
|
||||
},
|
||||
Module: {
|
||||
keyFields: ["id"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
defaultOptions: {
|
||||
watchQuery: {
|
||||
fetchPolicy: "cache-and-network",
|
||||
errorPolicy: "all",
|
||||
},
|
||||
},
|
||||
});
|
||||
20
apps/web/src/lib/i18n.ts
Normal file
20
apps/web/src/lib/i18n.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { initI18n, useTranslation } from "@investplay/i18n";
|
||||
import { env } from "./env";
|
||||
|
||||
const i18nInstance = initI18n({
|
||||
lng: env.DEFAULT_LOCALE,
|
||||
});
|
||||
|
||||
export default i18nInstance;
|
||||
|
||||
export function useTrans() {
|
||||
return useTranslation();
|
||||
}
|
||||
|
||||
export function changeLanguage(lng: string): void {
|
||||
void i18nInstance.changeLanguage(lng);
|
||||
}
|
||||
|
||||
export function getCurrentLanguage(): string {
|
||||
return i18nInstance.language;
|
||||
}
|
||||
63
apps/web/src/lib/socket.ts
Normal file
63
apps/web/src/lib/socket.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { env } from "./env";
|
||||
|
||||
let socket: Socket | null = null;
|
||||
|
||||
export function connectSocket(token: string): Socket {
|
||||
if (socket?.connected) {
|
||||
return socket;
|
||||
}
|
||||
|
||||
socket = io(env.WS_URL, {
|
||||
auth: { token },
|
||||
transports: ["websocket", "polling"],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 10,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 10000,
|
||||
});
|
||||
|
||||
socket.on("connect_error", (error) => {
|
||||
console.error("Socket connection error:", error.message);
|
||||
});
|
||||
|
||||
socket.on("disconnect", (reason) => {
|
||||
if (reason === "io server disconnect") {
|
||||
socket?.connect();
|
||||
}
|
||||
});
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function disconnectSocket(): void {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSocket(): Socket | null {
|
||||
return socket;
|
||||
}
|
||||
|
||||
export interface SimulationEventPayload {
|
||||
sessionId: string;
|
||||
tick: number;
|
||||
values: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface AICoachEventPayload {
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
content: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
id: string;
|
||||
type: "xp" | "badge" | "assignment" | "announcement";
|
||||
title: string;
|
||||
body: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
31
apps/web/src/main.tsx
Normal file
31
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./styles/globals.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 2,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) {
|
||||
throw new Error("Root element not found");
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
226
apps/web/src/pages/DashboardPage.tsx
Normal file
226
apps/web/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cn, formatXP } from "@investplay/ui";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { XPBar } from "@/components/gamification/XPBar";
|
||||
import { StreakCounter } from "@/components/gamification/StreakCounter";
|
||||
import { BadgeGrid } from "@/components/gamification/BadgeGrid";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useGamificationStore } from "@/stores/gamification.store";
|
||||
import {
|
||||
TrendingUp,
|
||||
BookOpen,
|
||||
Gamepad2,
|
||||
Brain,
|
||||
ArrowRight,
|
||||
Flame,
|
||||
Trophy,
|
||||
Clock,
|
||||
Target,
|
||||
} from "lucide-react";
|
||||
|
||||
const recentActivity = [
|
||||
{ type: "lesson", label: "Completed: Budgeting Basics", time: "2h ago", icon: BookOpen },
|
||||
{ type: "challenge", label: "Scored 85% on Emergency Fund", time: "1d ago", icon: Gamepad2 },
|
||||
{ type: "badge", label: "Earned: First Steps badge", time: "3d ago", icon: Trophy },
|
||||
];
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { xp, level, streaks, badges } = useGamificationStore();
|
||||
|
||||
const today = new Date().toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const earnedBadgeIds = new Set(badges.map((b) => b.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t("auth.welcomeBack")}, {user?.firstName ?? "Learner"}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{today}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/10">
|
||||
<TrendingUp className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-muted-foreground">{t("gamification:level.label")}</p>
|
||||
<p className="text-lg font-bold">{level}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-orange-100 dark:bg-orange-900">
|
||||
<Flame className="h-5 w-5 text-orange-600 dark:text-orange-300" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-muted-foreground">{t("gamification:streaks.label")}</p>
|
||||
<p className="text-lg font-bold">{streaks.current} days</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900">
|
||||
<BookOpen className="h-5 w-5 text-blue-600 dark:text-blue-300" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-muted-foreground">Lessons</p>
|
||||
<p className="text-lg font-bold">12</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-purple-100 dark:bg-purple-900">
|
||||
<Trophy className="h-5 w-5 text-purple-600 dark:text-purple-300" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-muted-foreground">{t("gamification:badges.title")}</p>
|
||||
<p className="text-lg font-bold">{badges.length}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<XPBar className="bg-card rounded-xl border border-border p-4" size="lg" />
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5 text-primary" />
|
||||
Continue Learning
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<p className="font-medium">Budgeting Basics</p>
|
||||
<p className="text-sm text-muted-foreground">Module 2 of 6</p>
|
||||
</div>
|
||||
<Badge variant="secondary">In Progress</Badge>
|
||||
</div>
|
||||
<Progress value={33} className="h-2" />
|
||||
</div>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link to="/learn">
|
||||
{t("actions.continue")}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-primary" />
|
||||
Weekly Goal
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative mb-4">
|
||||
<svg className="h-24 w-24 -rotate-90" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="42" fill="none" stroke="hsl(var(--secondary))" strokeWidth="8" />
|
||||
<circle
|
||||
cx="50" cy="50" r="42"
|
||||
fill="none"
|
||||
stroke="hsl(var(--accent))"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 42}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 42 * (1 - 0.6)}`}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-2xl font-bold">60%</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">3 of 5 lessons</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{recentActivity.map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-secondary">
|
||||
<item.icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm">{item.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{item.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button asChild variant="outline" className="w-full justify-start">
|
||||
<Link to="/learn">
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
Start Next Lesson
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full justify-start">
|
||||
<Link to="/play">
|
||||
<Gamepad2 className="mr-2 h-4 w-4" />
|
||||
Try a Challenge
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full justify-start">
|
||||
<Link to="/practice">
|
||||
<Brain className="mr-2 h-4 w-4" />
|
||||
View Portfolio
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{badges.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t("gamification:badges.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BadgeGrid badges={badges} earnedBadgeIds={earnedBadgeIds} limit={6} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
300
apps/web/src/pages/HomePage.tsx
Normal file
300
apps/web/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cn } from "@investplay/ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
BookOpen,
|
||||
Gamepad2,
|
||||
Brain,
|
||||
GraduationCap,
|
||||
ArrowRight,
|
||||
Star,
|
||||
TrendingUp,
|
||||
Shield,
|
||||
Users,
|
||||
Sparkles,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: "Learn",
|
||||
description: "Interactive lessons on personal finance, investing, and economics.",
|
||||
color: "from-blue-500 to-cyan-500",
|
||||
},
|
||||
{
|
||||
icon: Gamepad2,
|
||||
title: "Play",
|
||||
description: "Risk-free simulations and challenges that test your financial skills.",
|
||||
color: "from-orange-500 to-red-500",
|
||||
},
|
||||
{
|
||||
icon: Brain,
|
||||
title: "Practice",
|
||||
description: "Hands-on portfolio management with real-time market simulations.",
|
||||
color: "from-emerald-500 to-teal-500",
|
||||
},
|
||||
{
|
||||
icon: GraduationCap,
|
||||
title: "Teach",
|
||||
description: "Tools for educators to create classrooms and track student progress.",
|
||||
color: "from-purple-500 to-pink-500",
|
||||
},
|
||||
];
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: Users,
|
||||
title: "Join",
|
||||
description: "Create your free account in seconds.",
|
||||
step: "01",
|
||||
},
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: "Learn",
|
||||
description: "Complete interactive lessons at your own pace.",
|
||||
step: "02",
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: "Apply",
|
||||
description: "Test your knowledge in simulations and challenges.",
|
||||
step: "03",
|
||||
},
|
||||
];
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
name: "Maria K.",
|
||||
role: "Student",
|
||||
text: "InvestPlay made learning about investing actually fun. The simulations helped me understand risks without losing real money.",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
name: "John D.",
|
||||
role: "Teacher",
|
||||
text: "My students are more engaged than ever. The classroom features make it easy to track progress and assign lessons.",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
name: "Elena P.",
|
||||
role: "Professional",
|
||||
text: "Wish I had this when I was starting out. The AI coach is incredibly helpful for understanding complex concepts.",
|
||||
rating: 5,
|
||||
},
|
||||
];
|
||||
|
||||
const trustedBy = [
|
||||
"FinEdu Alliance",
|
||||
"EduTech Europe",
|
||||
"Future Finance",
|
||||
"Econ Academy",
|
||||
];
|
||||
|
||||
export function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="relative overflow-hidden px-4 py-20 sm:px-6 sm:py-28 lg:px-8">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-accent/5 to-primary/10" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-primary/10 via-transparent to-transparent" />
|
||||
|
||||
<div className="relative mx-auto max-w-7xl text-center">
|
||||
<Badge variant="secondary" className="mb-6">
|
||||
<Sparkles className="mr-1 h-3 w-3" />
|
||||
Financial Literacy for Everyone
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl">
|
||||
Master Your
|
||||
<span className="block bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
|
||||
Financial Future
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">
|
||||
Learn personal finance through interactive lessons, risk-free simulations,
|
||||
and engaging challenges. Build skills that last a lifetime.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
|
||||
<Button size="lg" asChild>
|
||||
<Link to="/register">
|
||||
Start Learning Free
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" asChild>
|
||||
<Link to="/teach">For Schools</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold">Everything you need to learn finance</h2>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Four pillars of financial education
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{features.map((feature) => (
|
||||
<Card key={feature.title} className="group relative overflow-hidden transition-shadow hover:shadow-lg">
|
||||
<CardContent className="p-6">
|
||||
<div
|
||||
className={cn(
|
||||
"mb-4 inline-flex rounded-xl bg-gradient-to-br p-3 text-white",
|
||||
feature.color,
|
||||
)}
|
||||
>
|
||||
<feature.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold">{feature.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{feature.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-secondary/50 px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold">How it works</h2>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Three simple steps to financial literacy
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
{steps.map((step, i) => (
|
||||
<div key={step.title} className="relative text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
|
||||
<step.icon className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<span className="text-sm font-bold text-primary">Step {step.step}</span>
|
||||
<h3 className="mt-2 text-xl font-semibold">{step.title}</h3>
|
||||
<p className="mt-1 text-muted-foreground">{step.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold">Loved by learners everywhere</h2>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{testimonials.map((t) => (
|
||||
<Card key={t.name}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex gap-1 mb-3">
|
||||
{Array.from({ length: t.rating }).map((_, i) => (
|
||||
<Star key={i} className="h-4 w-4 fill-amber-400 text-amber-400" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">“{t.text}”</p>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{t.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{t.role}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-secondary/50 px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold">Simple, transparent pricing</h2>
|
||||
<p className="mt-2 text-muted-foreground">Start free, upgrade when you need more</p>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2 max-w-3xl mx-auto">
|
||||
<Card className="relative">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-xl font-bold">Freemium</h3>
|
||||
<p className="mt-2 text-3xl font-bold">Free</p>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{["All basic lessons", "3 simulations", "Basic AI Coach", "Progress tracking"].map((item) => (
|
||||
<li key={item} className="flex items-center gap-2 text-sm">
|
||||
<Check className="h-4 w-4 text-accent" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button className="mt-6 w-full" asChild>
|
||||
<Link to="/register">Get Started</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="relative ring-2 ring-primary">
|
||||
<Badge className="absolute -top-2.5 right-4">Popular</Badge>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-xl font-bold">Institution</h3>
|
||||
<p className="mt-2 text-3xl font-bold">Contact us</p>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{["All lessons & simulations", "Unlimited AI Coach", "Classroom management", "Analytics & reports", "Priority support"].map((item) => (
|
||||
<li key={item} className="flex items-center gap-2 text-sm">
|
||||
<Check className="h-4 w-4 text-accent" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button className="mt-6 w-full" variant="outline">
|
||||
Contact Sales
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<p className="text-center text-sm font-medium text-muted-foreground mb-6">
|
||||
Trusted by
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-8 opacity-50">
|
||||
{trustedBy.map((name) => (
|
||||
<span key={name} className="text-lg font-bold">{name}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-gradient-to-r from-primary to-accent px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<h2 className="text-3xl font-bold text-white">
|
||||
Ready to master your financial future?
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-white/80">
|
||||
Join thousands of learners building real financial skills.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
|
||||
<Button size="lg" variant="secondary" asChild>
|
||||
<Link to="/register">
|
||||
Get Started Free
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="border-white/20 text-white hover:bg-white/10 hover:text-white"
|
||||
asChild
|
||||
>
|
||||
<Link to="/learn">Explore Lessons</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
apps/web/src/pages/LearnPage.tsx
Normal file
139
apps/web/src/pages/LearnPage.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { cn } from "@investplay/ui";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TrackCard } from "@/components/curriculum/TrackCard";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { CurriculumTrack } from "@investplay/types";
|
||||
import { Search, Filter } from "lucide-react";
|
||||
|
||||
interface TrackData {
|
||||
track: CurriculumTrack;
|
||||
title: string;
|
||||
description: string;
|
||||
moduleCount: number;
|
||||
estimatedHours: number;
|
||||
progress: number;
|
||||
difficulty: "beginner" | "intermediate" | "advanced";
|
||||
}
|
||||
|
||||
const tracks: TrackData[] = [
|
||||
{
|
||||
track: CurriculumTrack.SURVIVAL_BASICS,
|
||||
title: "Survival Basics",
|
||||
description: "Essential personal finance skills: budgeting, saving, and avoiding common pitfalls.",
|
||||
moduleCount: 6,
|
||||
estimatedHours: 8,
|
||||
progress: 33,
|
||||
difficulty: "beginner",
|
||||
},
|
||||
{
|
||||
track: CurriculumTrack.CREDIT_DEBT,
|
||||
title: "Credit & Debt",
|
||||
description: "Understand credit scores, loans, interest rates, and debt management strategies.",
|
||||
moduleCount: 5,
|
||||
estimatedHours: 7,
|
||||
progress: 0,
|
||||
difficulty: "beginner",
|
||||
},
|
||||
{
|
||||
track: CurriculumTrack.WEALTH_BUILDER,
|
||||
title: "Wealth Builder",
|
||||
description: "Learn investing fundamentals, compound interest, and long-term wealth building.",
|
||||
moduleCount: 8,
|
||||
estimatedHours: 12,
|
||||
progress: 0,
|
||||
difficulty: "intermediate",
|
||||
},
|
||||
{
|
||||
track: CurriculumTrack.ANTI_HYPE,
|
||||
title: "Anti-Hype",
|
||||
description: "Develop critical thinking about market trends, scams, and get-rich-quick schemes.",
|
||||
moduleCount: 4,
|
||||
estimatedHours: 5,
|
||||
progress: 0,
|
||||
difficulty: "intermediate",
|
||||
},
|
||||
{
|
||||
track: CurriculumTrack.MACROECONOMICS,
|
||||
title: "Macroeconomics",
|
||||
description: "Understand inflation, monetary policy, and how the broader economy works.",
|
||||
moduleCount: 6,
|
||||
estimatedHours: 9,
|
||||
progress: 0,
|
||||
difficulty: "advanced",
|
||||
},
|
||||
];
|
||||
|
||||
type DifficultyFilter = "all" | "beginner" | "intermediate" | "advanced";
|
||||
|
||||
export function LearnPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState("");
|
||||
const [difficulty, setDifficulty] = useState<DifficultyFilter>("all");
|
||||
|
||||
const filtered = tracks.filter((track) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
track.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
track.description.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesDifficulty =
|
||||
difficulty === "all" || track.difficulty === difficulty;
|
||||
return matchesSearch && matchesDifficulty;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("nav.learn")}</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Explore our financial literacy tracks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder={t("actions.search") + " lessons..."}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={difficulty}
|
||||
onValueChange={(v) => setDifficulty(v as DifficultyFilter)}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="beginner">Beginner</TabsTrigger>
|
||||
<TabsTrigger value="intermediate">Intermediate</TabsTrigger>
|
||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("empty.noResults")}
|
||||
description="Try adjusting your search or filter."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((track) => (
|
||||
<TrackCard
|
||||
key={track.track}
|
||||
{...track}
|
||||
onClick={() => navigate(`/learn/${track.track.toLowerCase()}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
227
apps/web/src/pages/LessonPage.tsx
Normal file
227
apps/web/src/pages/LessonPage.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn, formatXP } from "@investplay/ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Breadcrumbs } from "@/components/layout/Breadcrumbs";
|
||||
import { BlockRenderer } from "@/components/curriculum/BlockRenderer";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
MessageCircle,
|
||||
X,
|
||||
Sparkles,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { LessonBlock, BlockType } from "@investplay/types";
|
||||
|
||||
const mockBlocks: LessonBlock[] = [
|
||||
{
|
||||
id: "b1",
|
||||
type: "HERO" as BlockType,
|
||||
order: 1,
|
||||
content: {
|
||||
title: "Creating Your First Budget",
|
||||
description: "Learn the 50/30/20 rule and start managing your money effectively.",
|
||||
},
|
||||
config: null,
|
||||
},
|
||||
{
|
||||
id: "b2",
|
||||
type: "TEXT" as BlockType,
|
||||
order: 2,
|
||||
content: {
|
||||
text: "A budget is a plan for your money. It helps you track income, control spending, and save for goals. The most popular method is the 50/30/20 rule: 50% for needs, 30% for wants, and 20% for savings.",
|
||||
variant: "body",
|
||||
},
|
||||
config: null,
|
||||
},
|
||||
{
|
||||
id: "b3",
|
||||
type: "QUIZ" as BlockType,
|
||||
order: 3,
|
||||
content: {
|
||||
question: "What percentage of income should go to 'needs' in the 50/30/20 rule?",
|
||||
options: ["30%", "50%", "20%", "60%"],
|
||||
type: "multiple-choice",
|
||||
correctAnswer: "50%",
|
||||
explanation: "The 50/30/20 rule allocates 50% of income to needs, 30% to wants, and 20% to savings.",
|
||||
},
|
||||
config: null,
|
||||
},
|
||||
];
|
||||
|
||||
const totalLessons = 6;
|
||||
const currentLessonNumber = 3;
|
||||
|
||||
export function LessonPage() {
|
||||
const { moduleId, lessonId } = useParams<{ moduleId: string; lessonId: string }>();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [quizResults, setQuizResults] = useState<Record<string, { correct: boolean; answer: string | string[] }>>({});
|
||||
const [aiCoachOpen, setAiCoachOpen] = useState(false);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
const progress = (currentLessonNumber / totalLessons) * 100;
|
||||
|
||||
const handleQuizAnswer = (blockId: string, answer: string | string[]) => {
|
||||
const block = mockBlocks.find((b) => b.id === blockId);
|
||||
if (!block) return;
|
||||
|
||||
const correctAnswer = block.content.correctAnswer as string;
|
||||
const correct = Array.isArray(answer)
|
||||
? answer.every((a) => (correctAnswer as string[]).includes(a))
|
||||
: answer === correctAnswer;
|
||||
|
||||
setQuizResults((prev) => ({
|
||||
...prev,
|
||||
[blockId]: { correct, answer },
|
||||
}));
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
setCompleted(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Breadcrumbs />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/learn/${moduleId}`)}
|
||||
className="gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Module
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Zap className="h-3 w-3 text-accent" />
|
||||
{formatXP(75)} XP
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Lesson {currentLessonNumber} of {totalLessons}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress value={progress} className="h-2" />
|
||||
|
||||
{completed ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-accent/30 bg-accent/5 p-12 text-center">
|
||||
<CheckCircle2 className="h-16 w-16 text-accent mb-4" />
|
||||
<h2 className="text-2xl font-bold">Lesson Complete!</h2>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
You earned {formatXP(75)} XP for completing this lesson.
|
||||
</p>
|
||||
<div className="mt-6 flex gap-4">
|
||||
<Button onClick={() => navigate(`/learn/${moduleId}`)} variant="outline">
|
||||
Back to Module
|
||||
</Button>
|
||||
<Button onClick={() => navigate(`/learn/${moduleId}/${Number(lessonId) + 1}`)}>
|
||||
Next Lesson
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_300px]">
|
||||
<div className="space-y-8">
|
||||
{mockBlocks.map((block) => (
|
||||
<BlockRenderer
|
||||
key={block.id}
|
||||
block={block}
|
||||
onQuizAnswer={handleQuizAnswer}
|
||||
quizResults={quizResults}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/learn/${moduleId}/${Number(lessonId) - 1}`)}
|
||||
disabled={currentLessonNumber <= 1}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button onClick={handleComplete} className="gap-2">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Complete Lesson
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/learn/${moduleId}/${Number(lessonId) + 1}`)}
|
||||
disabled={currentLessonNumber >= totalLessons}
|
||||
>
|
||||
Next
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 right-0 z-40 w-80 border-l border-border bg-card p-4 shadow-lg transition-transform lg:sticky lg:top-20 lg:h-[calc(100vh-6rem)]",
|
||||
aiCoachOpen ? "translate-x-0" : "translate-x-full lg:translate-x-0 lg:hidden",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<h3 className="font-semibold">AI Coach</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setAiCoachOpen(false)}
|
||||
className="lg:hidden"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex h-[calc(100%-3rem)] flex-col">
|
||||
<div className="flex-1 space-y-3 overflow-y-auto">
|
||||
<div className="rounded-lg bg-secondary p-3 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
Hi! I'm your AI Coach. Ask me anything about budgeting!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-border mt-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
placeholder="Ask a question..."
|
||||
aria-label="Ask AI Coach"
|
||||
/>
|
||||
<Button size="sm">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setAiCoachOpen(true)}
|
||||
className="fixed bottom-20 right-4 z-30 h-12 w-12 rounded-full shadow-lg lg:hidden"
|
||||
size="icon"
|
||||
aria-label="Open AI Coach"
|
||||
>
|
||||
<MessageCircle className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
apps/web/src/pages/ModulePage.tsx
Normal file
135
apps/web/src/pages/ModulePage.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn, formatXP } from "@investplay/ui";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Breadcrumbs } from "@/components/layout/Breadcrumbs";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Lock,
|
||||
Clock,
|
||||
Sparkles,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
||||
const mockLessons = [
|
||||
{ id: "1", title: "What is a Budget?", duration: "10 min", xp: 50, completed: true },
|
||||
{ id: "2", title: "Tracking Your Expenses", duration: "15 min", xp: 50, completed: true },
|
||||
{ id: "3", title: "Creating Your First Budget", duration: "20 min", xp: 75, completed: false },
|
||||
{ id: "4", title: "The 50/30/20 Rule", duration: "15 min", xp: 75, completed: false },
|
||||
{ id: "5", title: "Budgeting Tools & Apps", duration: "10 min", xp: 50, completed: false },
|
||||
{ id: "6", title: "Module Quiz", duration: "15 min", xp: 100, completed: false },
|
||||
];
|
||||
|
||||
export function ModulePage() {
|
||||
const { moduleId } = useParams<{ moduleId: string }>();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const moduleTitle = "Budgeting Basics";
|
||||
const moduleDescription = "Learn how to create and maintain a personal budget that works for you.";
|
||||
const completedCount = mockLessons.filter((l) => l.completed).length;
|
||||
const progress = (completedCount / mockLessons.length) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Breadcrumbs />
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate("/learn")}
|
||||
className="gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Tracks
|
||||
</Button>
|
||||
|
||||
<div className="rounded-xl bg-gradient-to-br from-primary/10 via-accent/5 to-primary/5 p-6 md:p-8">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<Badge variant="secondary">Module</Badge>
|
||||
<h1 className="text-2xl font-bold md:text-3xl">{moduleTitle}</h1>
|
||||
<p className="text-muted-foreground max-w-xl">
|
||||
{moduleDescription}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
~1.5 hours
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Zap className="h-4 w-4 text-accent" />
|
||||
{formatXP(400)} XP total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="hidden md:inline-flex gap-2" size="lg">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
Ask AI Coach
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="font-medium">{completedCount} of {mockLessons.length} completed</span>
|
||||
<span className="text-muted-foreground">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{mockLessons.map((lesson, index) => {
|
||||
const isLocked = index > 0 && !mockLessons[index - 1].completed && !lesson.completed;
|
||||
const isActive = !lesson.completed && !isLocked;
|
||||
const isFirstUncompleted = isActive && (index === 0 || mockLessons[index - 1].completed);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={lesson.id}
|
||||
onClick={() => !isLocked && navigate(`/learn/${moduleId}/${lesson.id}`)}
|
||||
disabled={isLocked}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-4 rounded-xl border p-4 text-left transition-all",
|
||||
lesson.completed && "border-accent/30 bg-accent/5",
|
||||
isActive && "border-primary/50 bg-primary/5 hover:shadow-md",
|
||||
isLocked && "border-border opacity-50 cursor-not-allowed",
|
||||
isFirstUncompleted && "ring-2 ring-primary/30",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
{lesson.completed ? (
|
||||
<CheckCircle2 className="h-6 w-6 text-accent" />
|
||||
) : isLocked ? (
|
||||
<Lock className="h-5 w-5 text-muted-foreground" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{lesson.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{lesson.duration}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{formatXP(lesson.xp)} XP
|
||||
</Badge>
|
||||
{isFirstUncompleted && (
|
||||
<ArrowRight className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/web/src/pages/NotFoundPage.tsx
Normal file
38
apps/web/src/pages/NotFoundPage.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Home, ArrowLeft } from "lucide-react";
|
||||
|
||||
export function NotFoundPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 text-center">
|
||||
<div className="mb-8">
|
||||
<div className="text-8xl font-bold text-primary/20 select-none">404</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t("errors.notFound")}
|
||||
</h1>
|
||||
|
||||
<p className="mt-4 max-w-md text-muted-foreground">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
Let's get you back on track.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<Button asChild>
|
||||
<Link to="/dashboard">
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
apps/web/src/pages/PlayPage.tsx
Normal file
162
apps/web/src/pages/PlayPage.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { LeaderboardTable } from "@/components/gamification/LeaderboardTable";
|
||||
import {
|
||||
Wallet,
|
||||
Siren,
|
||||
TrendingDown,
|
||||
PiggyBank,
|
||||
Home,
|
||||
Car,
|
||||
BarChart3,
|
||||
Heart,
|
||||
Sparkles,
|
||||
Clock,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface Challenge {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
difficulty: "easy" | "medium" | "hard";
|
||||
timeMinutes: number;
|
||||
icon: LucideIcon;
|
||||
highScore?: number;
|
||||
daily?: boolean;
|
||||
}
|
||||
|
||||
const challenges: Challenge[] = [
|
||||
{ id: "budget", title: "Budget Challenge", description: "Manage a monthly budget with unexpected expenses.", difficulty: "easy", timeMinutes: 15, icon: Wallet, highScore: 850 },
|
||||
{ id: "emergency", title: "Emergency Fund", description: "Build an emergency fund while handling life events.", difficulty: "medium", timeMinutes: 20, icon: Siren, highScore: 720, daily: true },
|
||||
{ id: "crash", title: "Market Crash", description: "Navigate a sudden market downturn without panic selling.", difficulty: "hard", timeMinutes: 25, icon: TrendingDown, highScore: 680 },
|
||||
{ id: "savings", title: "Savings Habit", description: "Develop consistent saving habits over 12 months.", difficulty: "easy", timeMinutes: 10, icon: PiggyBank },
|
||||
{ id: "rent-vs", title: "Rent vs. Transport", description: "Optimize your housing and transportation costs.", difficulty: "medium", timeMinutes: 15, icon: Home },
|
||||
{ id: "debt", title: "Debt Repayment", description: "Pay off debt using snowball or avalanche methods.", difficulty: "medium", timeMinutes: 20, icon: Car },
|
||||
{ id: "esg", title: "ESG Portfolio", description: "Build a portfolio that balances returns with sustainability.", difficulty: "hard", timeMinutes: 30, icon: Heart },
|
||||
{ id: "interest", title: "Interest & APY", description: "Master compound interest and APY calculations.", difficulty: "easy", timeMinutes: 10, icon: BarChart3 },
|
||||
];
|
||||
|
||||
const mockLeaderboard = [
|
||||
{ userId: "1", rank: 1, firstName: "Maria", lastName: "K.", avatarUrl: null, xp: 2840, level: 12, badges: 8, tenantId: "t1" },
|
||||
{ userId: "2", rank: 2, firstName: "John", lastName: "D.", avatarUrl: null, xp: 2560, level: 11, badges: 6, tenantId: "t1" },
|
||||
{ userId: "3", rank: 3, firstName: "Elena", lastName: "P.", avatarUrl: null, xp: 2320, level: 10, badges: 7, tenantId: "t1" },
|
||||
{ userId: "4", rank: 4, firstName: "Alex", lastName: "M.", avatarUrl: null, xp: 2100, level: 9, badges: 5, tenantId: "t1" },
|
||||
{ userId: "5", rank: 5, firstName: "Sarah", lastName: "W.", avatarUrl: null, xp: 1950, level: 8, badges: 4, tenantId: "t1" },
|
||||
];
|
||||
|
||||
const difficultyColors = {
|
||||
easy: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-100",
|
||||
medium: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-100",
|
||||
hard: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-100",
|
||||
};
|
||||
|
||||
export function PlayPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dailyChallenge = challenges.find((c) => c.daily);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("nav.play")}</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Test your skills with interactive challenges
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{dailyChallenge && (
|
||||
<Card className="relative overflow-hidden bg-gradient-to-r from-primary/10 via-accent/5 to-primary/5">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
<dailyChallenge.icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="gap-1">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Daily Challenge
|
||||
</Badge>
|
||||
</div>
|
||||
<h2 className="mt-1 text-xl font-bold">{dailyChallenge.title}</h2>
|
||||
<p className="text-sm text-muted-foreground">{dailyChallenge.description}</p>
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{dailyChallenge.timeMinutes} min
|
||||
</span>
|
||||
<Badge className={difficultyColors[dailyChallenge.difficulty]}>
|
||||
{dailyChallenge.difficulty}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="shrink-0">Play Now</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{challenges.map((challenge) => (
|
||||
<Card key={challenge.id} className="group cursor-pointer transition-shadow hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<challenge.icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<Badge className={difficultyColors[challenge.difficulty]}>
|
||||
{challenge.difficulty}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold">{challenge.title}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
|
||||
{challenge.description}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{challenge.timeMinutes} min
|
||||
</span>
|
||||
{challenge.highScore && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="h-3 w-3 text-amber-500" />
|
||||
{challenge.highScore}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<Tabs defaultValue="global">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Leaderboard</h2>
|
||||
<TabsList>
|
||||
<TabsTrigger value="global">Global</TabsTrigger>
|
||||
<TabsTrigger value="classroom">Classroom</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="global">
|
||||
<LeaderboardTable entries={mockLeaderboard} currentUserId="1" />
|
||||
</TabsContent>
|
||||
<TabsContent value="classroom">
|
||||
<LeaderboardTable entries={mockLeaderboard.slice(0, 3)} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
apps/web/src/pages/PracticePage.tsx
Normal file
176
apps/web/src/pages/PracticePage.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
BarChart3,
|
||||
Calculator,
|
||||
PiggyBank,
|
||||
Landmark,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
ArrowRight,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
|
||||
export function PracticePage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("nav.practice")}</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Apply your knowledge with hands-on tools
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="bg-gradient-to-r from-accent/10 via-emerald-500/5 to-accent/5">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-accent/10">
|
||||
<Wallet className="h-6 w-6 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">Your Portfolio</h2>
|
||||
<p className="mt-1 text-3xl font-bold text-accent">€12,450.00</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-sm">
|
||||
<Badge variant="success" className="gap-1">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
+3.2%
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">All time return</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="shrink-0 gap-2">
|
||||
View Details
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Invested</p>
|
||||
<p className="font-semibold">€10,000</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Gain</p>
|
||||
<p className="font-semibold text-accent">€2,450</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Dividends</p>
|
||||
<p className="font-semibold">€320</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="calculators">
|
||||
<TabsList className="w-full sm:w-auto">
|
||||
<TabsTrigger value="calculators" className="flex-1 sm:flex-none">Calculators</TabsTrigger>
|
||||
<TabsTrigger value="labs" className="flex-1 sm:flex-none">Decision Labs</TabsTrigger>
|
||||
<TabsTrigger value="stats" className="flex-1 sm:flex-none">Statistics</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="calculators" className="mt-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900">
|
||||
<Calculator className="h-5 w-5 text-blue-600 dark:text-blue-300" />
|
||||
</div>
|
||||
<h3 className="font-semibold">Budget Planner</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Plan your monthly budget with the 50/30/20 rule
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900">
|
||||
<PiggyBank className="h-5 w-5 text-emerald-600 dark:text-emerald-300" />
|
||||
</div>
|
||||
<h3 className="font-semibold">Savings Calculator</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
See how compound interest grows your savings
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100 dark:bg-orange-900">
|
||||
<Landmark className="h-5 w-5 text-orange-600 dark:text-orange-300" />
|
||||
</div>
|
||||
<h3 className="font-semibold">Loan Calculator</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Compare loan options and total interest costs
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="labs" className="mt-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h3 className="font-semibold">Risk Tolerance Lab</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Discover your risk profile through interactive scenarios
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="mt-3">Start Lab</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<h3 className="font-semibold">Diversification Lab</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Build a diversified portfolio and analyze correlations
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="mt-3">Start Lab</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="stats" className="mt-6">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">Avg. Lesson Score</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">85%</p>
|
||||
<Progress value={85} className="mt-2 h-1.5" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">Simulations Played</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">24</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">12 this month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">Challenges Won</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">8</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">of 15 attempted</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
apps/web/src/pages/ProfilePage.tsx
Normal file
160
apps/web/src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn, formatXP } from "@investplay/ui";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { XPBar } from "@/components/gamification/XPBar";
|
||||
import { StreakCounter } from "@/components/gamification/StreakCounter";
|
||||
import { BadgeGrid } from "@/components/gamification/BadgeGrid";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useGamificationStore } from "@/stores/gamification.store";
|
||||
import {
|
||||
BookOpen,
|
||||
Gamepad2,
|
||||
Trophy,
|
||||
Clock,
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
|
||||
const activityTimeline = [
|
||||
{ type: "lesson", label: "Completed: Budgeting Basics", date: "2 hours ago", icon: BookOpen },
|
||||
{ type: "challenge", label: "Scored 85% on Emergency Fund", date: "1 day ago", icon: Gamepad2 },
|
||||
{ type: "badge", label: "Earned: First Steps badge", date: "3 days ago", icon: Trophy },
|
||||
{ type: "lesson", label: "Completed: Credit Scores 101", date: "5 days ago", icon: BookOpen },
|
||||
{ type: "challenge", label: "Completed Budget Challenge", date: "1 week ago", icon: Gamepad2 },
|
||||
];
|
||||
|
||||
export function ProfilePage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { xp, level, streaks, badges } = useGamificationStore();
|
||||
|
||||
const userInitials = user?.firstName && user?.lastName
|
||||
? `${user.firstName[0]}${user.lastName[0]}`
|
||||
: "U";
|
||||
|
||||
const earnedBadgeIds = new Set(badges.map((b) => b.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<Card>
|
||||
<CardContent className="p-6 md:p-8">
|
||||
<div className="flex flex-col items-center gap-6 sm:flex-row">
|
||||
<Avatar className="h-24 w-24">
|
||||
{user?.avatarUrl && <AvatarImage src={user.avatarUrl} alt={user.firstName} />}
|
||||
<AvatarFallback className="text-2xl">{userInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 text-center sm:text-left">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{user?.firstName} {user?.lastName}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{user?.email}</p>
|
||||
<div className="mt-3 flex flex-wrap justify-center sm:justify-start gap-3">
|
||||
<Badge variant="secondary">
|
||||
{t("gamification:level.current", { level })}
|
||||
</Badge>
|
||||
<StreakCounter
|
||||
current={streaks.current}
|
||||
longest={streaks.longest}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 text-center">
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{formatXP(xp)}</p>
|
||||
<p className="text-xs text-muted-foreground">Total XP</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{badges.length}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("gamification:badges.title")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">12</p>
|
||||
<p className="text-xs text-muted-foreground">Lessons</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<XPBar className="mt-6" size="lg" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t("gamification:badges.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{badges.length > 0 ? (
|
||||
<BadgeGrid badges={badges} earnedBadgeIds={earnedBadgeIds} />
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Trophy className="mx-auto h-8 w-8 mb-2" />
|
||||
<p className="text-sm">{t("gamification:achievements.empty")}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-primary" />
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{activityTimeline.map((item, i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-secondary">
|
||||
<item.icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
{i < activityTimeline.length - 1 && (
|
||||
<div className="mt-1 w-px flex-1 bg-border" />
|
||||
)}
|
||||
</div>
|
||||
<div className="pb-4">
|
||||
<p className="text-sm font-medium">{item.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{item.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-primary" />
|
||||
Learning Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{[
|
||||
{ label: "Lessons Completed", value: "12", icon: BookOpen },
|
||||
{ label: "Challenges Played", value: "15", icon: Gamepad2 },
|
||||
{ label: "Simulations Run", value: "8", icon: Trophy },
|
||||
{ label: "Current Streak", value: `${streaks.current}d`, icon: Calendar },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="rounded-lg bg-secondary p-4 text-center">
|
||||
<div className="flex justify-center mb-2">
|
||||
<stat.icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<p className="text-xl font-bold">{stat.value}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
apps/web/src/pages/SettingsPage.tsx
Normal file
216
apps/web/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { LanguageSwitcher } from "@/components/shared/LanguageSwitcher";
|
||||
import { ThemeToggle } from "@/components/shared/ThemeToggle";
|
||||
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import {
|
||||
User,
|
||||
Bell,
|
||||
Shield,
|
||||
Building2,
|
||||
Download,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
export function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const userInitials = user?.firstName && user?.lastName
|
||||
? `${user.firstName[0]}${user.lastName[0]}`
|
||||
: "U";
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
console.log("Delete account requested");
|
||||
setDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("nav.settings")}</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Manage your account preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
Profile
|
||||
</CardTitle>
|
||||
<CardDescription>Update your personal information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
{user?.avatarUrl && <AvatarImage src={user.avatarUrl} />}
|
||||
<AvatarFallback className="text-lg">{userInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<Button variant="outline" size="sm">Change Avatar</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Input
|
||||
label="First Name"
|
||||
defaultValue={user?.firstName}
|
||||
id="firstName"
|
||||
/>
|
||||
<Input
|
||||
label="Last Name"
|
||||
defaultValue={user?.lastName}
|
||||
id="lastName"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
defaultValue={user?.email}
|
||||
id="email"
|
||||
helperText="You cannot change your email address"
|
||||
disabled
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button>{t("actions.save")}</Button>
|
||||
<Button variant="outline">{t("actions.cancel")}</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
Preferences
|
||||
</CardTitle>
|
||||
<CardDescription>Customize your experience</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Language</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose your preferred language
|
||||
</p>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Theme</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose between light, dark, or system theme
|
||||
</p>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
Notifications
|
||||
</CardTitle>
|
||||
<CardDescription>Manage your notification preferences</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{["Lesson reminders", "Challenge invitations", "New badges and achievements", "Classroom announcements"].map(
|
||||
(item) => (
|
||||
<div key={item} className="flex items-center justify-between">
|
||||
<Label>{item}</Label>
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked
|
||||
className="h-4 w-4 rounded border-border text-primary"
|
||||
aria-label={item}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
Privacy & Data
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your data and privacy settings (GDPR)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Export My Data</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Download all your personal data in a machine-readable format
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-destructive">Delete Account</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Permanently delete your account and all associated data
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="gap-2"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5 text-primary" />
|
||||
Institution
|
||||
</CardTitle>
|
||||
<CardDescription>Your connected institution information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg bg-secondary p-4">
|
||||
<p className="text-sm font-medium">Not connected to any institution</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Join a classroom using an invite code to connect to your institution.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title="Delete Account"
|
||||
description="Are you sure you want to delete your account? This action is irreversible and all your data will be permanently removed."
|
||||
variant="destructive"
|
||||
confirmLabel="Delete my account"
|
||||
onConfirm={handleDeleteAccount}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
apps/web/src/stores/auth.store.ts
Normal file
67
apps/web/src/stores/auth.store.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { User } from "@investplay/types";
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (user: User, accessToken: string, refreshToken: string) => void;
|
||||
register: (user: User, accessToken: string, refreshToken: string) => void;
|
||||
logout: () => void;
|
||||
refreshAuth: (accessToken: string, refreshToken: string) => void;
|
||||
updateUser: (user: Partial<User>) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
login: (user, accessToken, refreshToken) =>
|
||||
set({
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
register: (user, accessToken, refreshToken) =>
|
||||
set({
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
logout: () =>
|
||||
set({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
refreshAuth: (accessToken, refreshToken) =>
|
||||
set({ accessToken, refreshToken }),
|
||||
updateUser: (partial) =>
|
||||
set((state) => ({
|
||||
user: state.user ? { ...state.user, ...partial } : null,
|
||||
})),
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
}),
|
||||
{
|
||||
name: "investplay-auth",
|
||||
partialize: (state) => ({
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
76
apps/web/src/stores/gamification.store.ts
Normal file
76
apps/web/src/stores/gamification.store.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { Badge, LeaderboardEntry, XPReason } from "@investplay/types";
|
||||
|
||||
interface GamificationState {
|
||||
xp: number;
|
||||
level: number;
|
||||
streaks: {
|
||||
current: number;
|
||||
longest: number;
|
||||
lastActivityDate: string | null;
|
||||
};
|
||||
badges: Badge[];
|
||||
leaderboard: LeaderboardEntry[];
|
||||
awardXP: (amount: number, reason: XPReason, referenceId: string) => void;
|
||||
updateStreak: (current: number, longest: number, lastActivityDate: string) => void;
|
||||
addBadge: (badge: Badge) => void;
|
||||
setLeaderboard: (entries: LeaderboardEntry[]) => void;
|
||||
}
|
||||
|
||||
const XP_PER_LEVEL = 500;
|
||||
|
||||
export function xpToNextLevel(level: number): number {
|
||||
return XP_PER_LEVEL * level;
|
||||
}
|
||||
|
||||
export function levelProgress(xp: number, level: number): number {
|
||||
const needed = xpToNextLevel(level);
|
||||
const currentLevelXp = (level - 1) * XP_PER_LEVEL * level / 2;
|
||||
const earnedInLevel = xp - currentLevelXp;
|
||||
return Math.min(Math.max(earnedInLevel / needed, 0), 1);
|
||||
}
|
||||
|
||||
export const useGamificationStore = create<GamificationState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
xp: 0,
|
||||
level: 1,
|
||||
streaks: {
|
||||
current: 0,
|
||||
longest: 0,
|
||||
lastActivityDate: null,
|
||||
},
|
||||
badges: [],
|
||||
leaderboard: [],
|
||||
awardXP: (amount) => {
|
||||
set((state) => {
|
||||
const newXp = state.xp + amount;
|
||||
let newLevel = state.level;
|
||||
while (newXp >= xpToNextLevel(newLevel)) {
|
||||
newLevel++;
|
||||
}
|
||||
return { xp: newXp, level: newLevel };
|
||||
});
|
||||
},
|
||||
updateStreak: (current, longest, lastActivityDate) =>
|
||||
set({ streaks: { current, longest, lastActivityDate } }),
|
||||
addBadge: (badge) =>
|
||||
set((state) => ({
|
||||
badges: state.badges.some((b) => b.id === badge.id)
|
||||
? state.badges
|
||||
: [...state.badges, badge],
|
||||
})),
|
||||
setLeaderboard: (entries) => set({ leaderboard: entries }),
|
||||
}),
|
||||
{
|
||||
name: "investplay-gamification",
|
||||
partialize: (state) => ({
|
||||
xp: state.xp,
|
||||
level: state.level,
|
||||
streaks: state.streaks,
|
||||
badges: state.badges,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
52
apps/web/src/stores/ui.store.ts
Normal file
52
apps/web/src/stores/ui.store.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "system";
|
||||
|
||||
interface UIState {
|
||||
sidebarCollapsed: boolean;
|
||||
theme: ThemeMode;
|
||||
locale: string;
|
||||
toggleSidebar: () => void;
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
setLocale: (locale: string) => void;
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeMode): void {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
root.classList.add(prefersDark ? "dark" : "light");
|
||||
} else {
|
||||
root.classList.add(theme);
|
||||
}
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
sidebarCollapsed: false,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
toggleSidebar: () =>
|
||||
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
|
||||
setTheme: (theme) => {
|
||||
applyTheme(theme);
|
||||
set({ theme });
|
||||
},
|
||||
setLocale: (locale) => set({ locale }),
|
||||
}),
|
||||
{
|
||||
name: "investplay-ui",
|
||||
onRehydrateStorage: () => {
|
||||
return (state) => {
|
||||
if (state) {
|
||||
applyTheme(state.theme);
|
||||
}
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
85
apps/web/src/styles/globals.css
Normal file
85
apps/web/src/styles/globals.css
Normal file
@@ -0,0 +1,85 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 210 40% 98%;
|
||||
--foreground: 222 47% 11%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222 47% 11%;
|
||||
--primary: 221 83% 53%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222 47% 11%;
|
||||
--muted: 210 40% 96%;
|
||||
--muted-foreground: 215 16% 47%;
|
||||
--accent: 160 60% 45%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214 32% 91%;
|
||||
--input: 214 32% 91%;
|
||||
--ring: 221 83% 53%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222 47% 11%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222 47% 11%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222 47% 11%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 222 47% 11%;
|
||||
--secondary: 217 33% 17%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217 33% 17%;
|
||||
--muted-foreground: 215 20% 65%;
|
||||
--accent: 160 60% 45%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217 33% 17%;
|
||||
--input: 217 33% 17%;
|
||||
--ring: 217 91% 60%;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
81
apps/web/tailwind.config.ts
Normal file
81
apps/web/tailwind.config.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{ts,tsx}",
|
||||
"../../packages/ui/src/**/*.{ts,tsx}",
|
||||
],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: "#eff6ff",
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
950: "#172554",
|
||||
},
|
||||
accent: {
|
||||
50: "#ecfdf5",
|
||||
100: "#d1fae5",
|
||||
200: "#a7f3d0",
|
||||
300: "#6ee7b7",
|
||||
400: "#34d399",
|
||||
500: "#10b981",
|
||||
600: "#059669",
|
||||
700: "#047857",
|
||||
800: "#065f46",
|
||||
900: "#064e3b",
|
||||
950: "#022c22",
|
||||
},
|
||||
surface: {
|
||||
50: "#f8fafc",
|
||||
100: "#f1f5f9",
|
||||
200: "#e2e8f0",
|
||||
300: "#cbd5e1",
|
||||
400: "#94a3b8",
|
||||
500: "#64748b",
|
||||
600: "#475569",
|
||||
700: "#334155",
|
||||
800: "#1e293b",
|
||||
900: "#0f172a",
|
||||
950: "#020617",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Inter", "system-ui", "-apple-system", "sans-serif"],
|
||||
mono: ["JetBrains Mono", "Fira Code", "monospace"],
|
||||
},
|
||||
animation: {
|
||||
"fade-in": "fadeIn 0.3s ease-in-out",
|
||||
"slide-up": "slideUp 0.3s ease-out",
|
||||
"slide-down": "slideDown 0.3s ease-out",
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
"0%": { opacity: "0" },
|
||||
"100%": { opacity: "1" },
|
||||
},
|
||||
slideUp: {
|
||||
"0%": { transform: "translateY(10px)", opacity: "0" },
|
||||
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||
},
|
||||
slideDown: {
|
||||
"0%": { transform: "translateY(-10px)", opacity: "0" },
|
||||
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
26
apps/web/tsconfig.json
Normal file
26
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./",
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@investplay/types": ["../../packages/types/src"],
|
||||
"@investplay/types/*": ["../../packages/types/src/*"],
|
||||
"@investplay/ui": ["../../packages/ui/src"],
|
||||
"@investplay/ui/*": ["../../packages/ui/src/*"],
|
||||
"@investplay/i18n": ["../../packages/i18n/src"],
|
||||
"@investplay/i18n/*": ["../../packages/i18n/src/*"],
|
||||
"@investplay/utils": ["../../packages/utils/src"],
|
||||
"@investplay/utils/*": ["../../packages/utils/src/*"],
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", ".turbo"]
|
||||
}
|
||||
38
apps/web/vite.config.ts
Normal file
38
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"@investplay/types": path.resolve(__dirname, "../../packages/types/src"),
|
||||
"@investplay/ui": path.resolve(__dirname, "../../packages/ui/src"),
|
||||
"@investplay/i18n": path.resolve(__dirname, "../../packages/i18n/src"),
|
||||
"@investplay/utils": path.resolve(__dirname, "../../packages/utils/src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3001",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/graphql": {
|
||||
target: "http://localhost:3001/graphql",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/socket.io": {
|
||||
target: "http://localhost:3001",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user