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

- 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:
Lefteris Notas
2026-06-12 19:32:57 +03:00
parent fa71c60cfc
commit e75f913f1c
226 changed files with 17547 additions and 0 deletions

20
apps/web/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
};

80
apps/web/src/App.tsx Normal file
View 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>
);
}

View 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>
);
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} &middot;{" "}
{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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 };

View 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 };

View 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 };

View 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 };

View 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,
};

View 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,
};

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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,
};

View 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 };

View 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,
};
}

View 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,
};
}

View 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,
});
}

View 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)");

View 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,
};
}

View 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>
);
}

View 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">
&copy; {new Date().getFullYear()} InvestPlay. All rights reserved.
</p>
</div>
</div>
</footer>
</div>
);
}

View 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
View 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
View 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;

View 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
View 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;
}

View 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
View 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>,
);

View 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>
);
}

View 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">&ldquo;{t.text}&rdquo;</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>
);
}

View 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>
);
}

View 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&apos;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>
);
}

View 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>
);
}

View 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&apos;re looking for doesn&apos;t exist or has been moved.
Let&apos;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>
);
}

View 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>
);
}

View 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">&euro;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">&euro;10,000</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Gain</p>
<p className="font-semibold text-accent">&euro;2,450</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Dividends</p>
<p className="font-semibold">&euro;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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
}),
},
),
);

View 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,
}),
},
),
);

View 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);
}
};
},
},
),
);

View 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;
}
}

View 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
View 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
View 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,
},
});