20 Commits

Author SHA1 Message Date
3767fc92d9 update
Some checks failed
Build and Push Docker Image on Tag / build (push) Failing after 10m0s
2026-04-21 13:54:15 +07:00
f233652acd add cicd
Some checks failed
Build and Push Docker Image on Tag / build (push) Failing after 14m28s
2026-04-21 11:59:45 +07:00
285ab987fd fix 2026-04-21 11:44:29 +07:00
309609fccb update 2026-04-18 23:16:52 +07:00
3e0b3f6a6d imported flash card 2026-04-16 15:08:05 +07:00
088c555515 update flash card, test 2026-04-15 00:41:02 +07:00
4bc39225ab Merge branch 'main' of https://git.renolation.com/renolation/english 2026-04-14 17:47:58 +07:00
427557ef96 sql 2026-04-14 17:47:55 +07:00
1736b8a68f sql 2026-04-14 17:42:59 +07:00
efd7fac42f update docker 2026-04-14 11:00:06 +07:00
01c5ccbd93 fix 2026-04-13 15:17:59 +07:00
77a0e38fa7 add dbiz, add history 2026-04-13 13:45:18 +07:00
409706457a fix 2026-04-13 10:05:22 +07:00
406d7039d6 refactor files 2026-04-12 23:36:14 +07:00
20ae176992 update real data 2026-04-12 23:12:29 +07:00
8de8b88a3d leader board + setting 2026-04-12 22:59:46 +07:00
857341132c nginx 2026-04-12 19:49:03 +07:00
53afcf5eb2 edge func 2026-04-12 19:07:31 +07:00
ec3d400e8a phase 2 2026-04-12 18:54:59 +07:00
28e866a64e feat: initial commit 2026-04-12 01:20:57 +07:00
243 changed files with 224208 additions and 156 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
dist
.env
.env.local
.env.*.local
.git
.gitignore
plans
docs
*.md
!README.md

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Docker — port exposed on host (default: 3000)
APP_PORT=3000
# Supabase — https://supabase.com/dashboard/project/_/settings/api
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
# Alternative key name (both are supported)
# VITE_SUPABASE_ANON_KEY=eyJ...
# GLM API — used by writing-check edge function (server-side only)
# Deploy with: supabase secrets set GLM_API_KEY=<your_key>
GLM_API_KEY=your_glm_api_key_here
# DBIZ API — https://ai-api.dbiz.com
# VITE_ prefix = exposed to browser (intentional, for direct streaming without edge function hop)
VITE_DBIZ_API_KEY=your_dbiz_api_key_here

View File

@@ -0,0 +1,45 @@
name: Build and Push Docker Image on Tag
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Extract tag name
id: tag
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Set up QEMU (for multi-platform)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
bootstrap: true
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and Push multi-platform image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ secrets.REGISTRY_URL }}/renolation/english-toeic:${{ steps.tag.outputs.TAG }}
${{ secrets.REGISTRY_URL }}/renolation/english-toeic:latest

3
.gitignore vendored
View File

@@ -70,6 +70,9 @@ __pycache__
prompt.md
ck.md
# TanStack Router generated
src/routeTree.gen.ts
# Generated runtime layout for release/install smoke tests
/.claude/

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

8
.idea/english.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="SqlNoDataSourceInspection" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>
</component>

13
.idea/material_theme_project_new.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="migrated" value="true" />
<option name="pristineConfig" value="false" />
<option name="userId" value="2c1a2bb7:18a988f44a2:-8000" />
<option name="version" value="8.13.2" />
</MTProjectMetadataState>
</option>
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/english.iml" filepath="$PROJECT_DIR$/.idea/english.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -0,0 +1,258 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as WritingRouteImport } from './routes/writing'
import { Route as VocabRouteImport } from './routes/vocab'
import { Route as ToeicRouteImport } from './routes/toeic'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ToeicIndexRouteImport } from './routes/toeic.index'
import { Route as ToeicSessionRouteImport } from './routes/toeic.session'
import { Route as ToeicResultRouteImport } from './routes/toeic.result'
import { Route as AuthRegisterRouteImport } from './routes/auth.register'
import { Route as AuthLoginRouteImport } from './routes/auth.login'
import { Route as ToeicPartPartIdRouteImport } from './routes/toeic.part.$partId'
const WritingRoute = WritingRouteImport.update({
id: '/writing',
path: '/writing',
getParentRoute: () => rootRouteImport,
} as any)
const VocabRoute = VocabRouteImport.update({
id: '/vocab',
path: '/vocab',
getParentRoute: () => rootRouteImport,
} as any)
const ToeicRoute = ToeicRouteImport.update({
id: '/toeic',
path: '/toeic',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ToeicIndexRoute = ToeicIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => ToeicRoute,
} as any)
const ToeicSessionRoute = ToeicSessionRouteImport.update({
id: '/session',
path: '/session',
getParentRoute: () => ToeicRoute,
} as any)
const ToeicResultRoute = ToeicResultRouteImport.update({
id: '/result',
path: '/result',
getParentRoute: () => ToeicRoute,
} as any)
const AuthRegisterRoute = AuthRegisterRouteImport.update({
id: '/auth/register',
path: '/auth/register',
getParentRoute: () => rootRouteImport,
} as any)
const AuthLoginRoute = AuthLoginRouteImport.update({
id: '/auth/login',
path: '/auth/login',
getParentRoute: () => rootRouteImport,
} as any)
const ToeicPartPartIdRoute = ToeicPartPartIdRouteImport.update({
id: '/part/$partId',
path: '/part/$partId',
getParentRoute: () => ToeicRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/toeic': typeof ToeicRouteWithChildren
'/vocab': typeof VocabRoute
'/writing': typeof WritingRoute
'/auth/login': typeof AuthLoginRoute
'/auth/register': typeof AuthRegisterRoute
'/toeic/result': typeof ToeicResultRoute
'/toeic/session': typeof ToeicSessionRoute
'/toeic/': typeof ToeicIndexRoute
'/toeic/part/$partId': typeof ToeicPartPartIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/vocab': typeof VocabRoute
'/writing': typeof WritingRoute
'/auth/login': typeof AuthLoginRoute
'/auth/register': typeof AuthRegisterRoute
'/toeic/result': typeof ToeicResultRoute
'/toeic/session': typeof ToeicSessionRoute
'/toeic': typeof ToeicIndexRoute
'/toeic/part/$partId': typeof ToeicPartPartIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/toeic': typeof ToeicRouteWithChildren
'/vocab': typeof VocabRoute
'/writing': typeof WritingRoute
'/auth/login': typeof AuthLoginRoute
'/auth/register': typeof AuthRegisterRoute
'/toeic/result': typeof ToeicResultRoute
'/toeic/session': typeof ToeicSessionRoute
'/toeic/': typeof ToeicIndexRoute
'/toeic/part/$partId': typeof ToeicPartPartIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/toeic'
| '/vocab'
| '/writing'
| '/auth/login'
| '/auth/register'
| '/toeic/result'
| '/toeic/session'
| '/toeic/'
| '/toeic/part/$partId'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/vocab'
| '/writing'
| '/auth/login'
| '/auth/register'
| '/toeic/result'
| '/toeic/session'
| '/toeic'
| '/toeic/part/$partId'
id:
| '__root__'
| '/'
| '/toeic'
| '/vocab'
| '/writing'
| '/auth/login'
| '/auth/register'
| '/toeic/result'
| '/toeic/session'
| '/toeic/'
| '/toeic/part/$partId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ToeicRoute: typeof ToeicRouteWithChildren
VocabRoute: typeof VocabRoute
WritingRoute: typeof WritingRoute
AuthLoginRoute: typeof AuthLoginRoute
AuthRegisterRoute: typeof AuthRegisterRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/writing': {
id: '/writing'
path: '/writing'
fullPath: '/writing'
preLoaderRoute: typeof WritingRouteImport
parentRoute: typeof rootRouteImport
}
'/vocab': {
id: '/vocab'
path: '/vocab'
fullPath: '/vocab'
preLoaderRoute: typeof VocabRouteImport
parentRoute: typeof rootRouteImport
}
'/toeic': {
id: '/toeic'
path: '/toeic'
fullPath: '/toeic'
preLoaderRoute: typeof ToeicRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/toeic/': {
id: '/toeic/'
path: '/'
fullPath: '/toeic/'
preLoaderRoute: typeof ToeicIndexRouteImport
parentRoute: typeof ToeicRoute
}
'/toeic/session': {
id: '/toeic/session'
path: '/session'
fullPath: '/toeic/session'
preLoaderRoute: typeof ToeicSessionRouteImport
parentRoute: typeof ToeicRoute
}
'/toeic/result': {
id: '/toeic/result'
path: '/result'
fullPath: '/toeic/result'
preLoaderRoute: typeof ToeicResultRouteImport
parentRoute: typeof ToeicRoute
}
'/auth/register': {
id: '/auth/register'
path: '/auth/register'
fullPath: '/auth/register'
preLoaderRoute: typeof AuthRegisterRouteImport
parentRoute: typeof rootRouteImport
}
'/auth/login': {
id: '/auth/login'
path: '/auth/login'
fullPath: '/auth/login'
preLoaderRoute: typeof AuthLoginRouteImport
parentRoute: typeof rootRouteImport
}
'/toeic/part/$partId': {
id: '/toeic/part/$partId'
path: '/part/$partId'
fullPath: '/toeic/part/$partId'
preLoaderRoute: typeof ToeicPartPartIdRouteImport
parentRoute: typeof ToeicRoute
}
}
}
interface ToeicRouteChildren {
ToeicResultRoute: typeof ToeicResultRoute
ToeicSessionRoute: typeof ToeicSessionRoute
ToeicIndexRoute: typeof ToeicIndexRoute
ToeicPartPartIdRoute: typeof ToeicPartPartIdRoute
}
const ToeicRouteChildren: ToeicRouteChildren = {
ToeicResultRoute: ToeicResultRoute,
ToeicSessionRoute: ToeicSessionRoute,
ToeicIndexRoute: ToeicIndexRoute,
ToeicPartPartIdRoute: ToeicPartPartIdRoute,
}
const ToeicRouteWithChildren = ToeicRoute._addFileChildren(ToeicRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ToeicRoute: ToeicRouteWithChildren,
VocabRoute: VocabRoute,
WritingRoute: WritingRoute,
AuthLoginRoute: AuthLoginRoute,
AuthRegisterRoute: AuthRegisterRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

595
Claude.md
View File

@@ -1,7 +1,8 @@
# Claude Project Context — English Learning App (TOEIC Focus)
> File này dùng để cung cấp context đầy đủ cho Claude khi làm việc với dự án.
> File này cung cấp context đầy đủ cho Claude khi làm việc với dự án.
> Cập nhật file này mỗi khi có quyết định kiến trúc mới.
> **Last updated**: Phase 2 done — thêm Phase 3 Retention & Monetization, đẩy Speaking AI và Full TOEIC sang Phase 4 & 5
---
@@ -11,15 +12,7 @@
**Target users**: Sinh viên, người đi làm tại Việt Nam cần TOEIC, IELTS, hoặc học tiếng Anh tổng quát
**Focus chính**: TOEIC (mở rộng market), sau đó IELTS
**Giai đoạn hiện tại**: Phase 1 — MVP, validate market
---
## Mục tiêu Phase 1
- Ra sản phẩm web dùng thử được, **không cần đăng nhập**
- Validate 2 tính năng cốt lõi: luyện đề TOEIC + AI writing checker
- Test xem có người dùng thật, có traction không
- **Không over-engineer** — Supabase tạm thời, đổi sau nếu có traction
**Roadmap**: 5 phases — MVP → Auth & Progress → Retention & Monetization → Speaking AI → Full TOEIC
---
@@ -28,59 +21,66 @@
### Frontend
| Layer | Tech | Ghi chú |
|---|---|---|
| Framework | **React** (Vite) | |
| Routing | **TanStack Router** | Type-safe routing |
| Framework | **React** + **Vite** + **TypeScript** | |
| Routing | **TanStack Router** | File-based, type-safe |
| Server state | **TanStack Query** | Fetch, cache, sync API data |
| Client state | **Zustand** | UI state, localStorage sync |
| Styling | **Tailwind CSS** | Mobile-first |
| Client state | **Zustand** | UI state + localStorage persist |
| Styling | **Tailwind CSS** | Desktop-first |
| UI Components | **shadcn/ui** | Dùng khi cần, không bắt buộc |
### Backend (Phase 1 — tạm thời)
| Layer | Tech | Ghi chú |
|---|---|---|
| Database | **Supabase** (PostgreSQL managed) | Free tier, migrate sau |
| API | **Supabase JS SDK** | Gọi thẳng từ React |
| Server functions | **Supabase Edge Functions** (Deno) | Xử lý AI API call, giấu key |
> ⚠️ **Supabase chỉ dùng Phase 1** để ra sản phẩm nhanh.
> Phase 2 migrate sang **NestJS + PostgreSQL native** khi có traction.
> Schema PostgreSQL thiết kế chuẩn ngay từ đầu để migrate không đau.
### Backend (Phase 2 — kế hoạch)
| Layer | Tech |
### Design System (từ Stitch export)
| Token | Value |
|---|---|
| Framework | **NestJS** |
| ORM | **Prisma** hoặc **TypeORM** |
| Database | **PostgreSQL** (self-hosted) |
| Auth | **JWT** + Google OAuth + Zalo OAuth |
| Mobile | **Flutter** (iOS + Android) |
| Font | Plus Jakarta Sans + Material Symbols Outlined |
| Primary | #2563EB |
| Success | #16A34A |
| Danger | #DC2626 |
| Background | #F8FAFC |
| Card | #FFFFFF |
| Border radius | 1216px |
| Shadow | soft, subtle |
### Responsive Layout
| Breakpoint | Layout |
|---|---|
| Desktop (1280px) | Sidebar trái cố định (240px) + main content — **LAYOUT CHÍNH** |
| Tablet (768px) | Sidebar thu gọn icon-only |
| Mobile (375px) | Ẩn sidebar, hiện bottom navigation bar |
### Backend
| Phase | Tech | Ghi chú |
|---|---|---|
| Phase 1 | **Supabase** (PostgreSQL + Edge Functions + JS SDK) | Tạm thời, migrate sau |
| Phase 2+ | **NestJS** + **PostgreSQL** native | Khi có traction |
> ⚠️ Supabase chỉ dùng Phase 1. Schema PostgreSQL thiết kế chuẩn ngay từ đầu để migrate không đau.
### AI
| Layer | Tech | Ghi chú |
|---|---|---|
| Provider | **GLM (Z.ai API)**`open.bigmodel.cn` | Rẻ, OpenAI-compatible |
| Model | **GLM-4** hoặc **GLM-4.7** | Test chất lượng chấm writing |
| Fallback | OpenAI / Claude API | Nếu GLM không đủ chất lượng |
> GLM API tương thích OpenAI format → swap provider không cần đổi code.
| Provider | **GLM (Z.ai API)** | Rẻ, OpenAI-compatible format |
| Endpoint | `open.bigmodel.cn/api/paas/v4` | |
| Model | GLM-4 / GLM-4.7 | |
| Fallback | OpenAI / Claude API | Swap dễ vì API compatible |
| Gọi từ | Supabase Edge Function (Phase 1) → NestJS service (Phase 2+) | Giấu API key |
### Deploy
| Layer | Tech |
|---|---|
| Frontend | **Self-hosted server** (có sẵn) |
| Backend | **Self-hosted server** (có sẵn) |
| Database | Supabase Cloud (Phase 1) → self-hosted PostgreSQL (Phase 2) |
| Frontend | Self-hosted server (có sẵn) |
| Backend | Self-hosted server (có sẵn) |
| Database | Supabase Cloud (Phase 1) → self-hosted PostgreSQL (Phase 2+) |
---
## Database Schema (PostgreSQL — Phase 1)
## Database Schema (PostgreSQL)
```sql
-- Câu hỏi TOEIC
CREATE TABLE questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
part INT NOT NULL, -- 1 đến 7
type TEXT, -- photo, q&a, incomplete_sentence, etc.
type TEXT, -- photo | q&a | incomplete_sentence | etc.
content TEXT NOT NULL, -- nội dung câu hỏi / đoạn văn
options JSONB, -- ["A. ...", "B. ...", "C. ...", "D. ..."]
answer TEXT NOT NULL, -- "A"
@@ -101,158 +101,411 @@ CREATE TABLE vocab (
created_at TIMESTAMPTZ DEFAULT now()
);
-- Phase 2 sẽ thêm: users, user_progress, writing_submissions, test_sessions
-- Phase 2+
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
password TEXT NOT NULL, -- hashed
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE user_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
type TEXT, -- test | vocab | writing
reference_id UUID,
data JSONB,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE writing_submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
content TEXT NOT NULL,
feedback JSONB,
created_at TIMESTAMPTZ DEFAULT now()
);
```
---
## API Design
## TypeScript Interfaces
### Supabase SDK (Phase 1)
```typescript
// Lấy câu hỏi theo Part
supabase.from('questions').select('*').eq('part', 1).limit(10)
interface Question {
id: string
part: number
type: string
content: string
options: string[]
answer: string
explanation: string
audioUrl?: string
imageUrl?: string
}
// Lấy từ vựng theo chủ đề
supabase.from('vocab').select('*').eq('topic', 'business')
```
interface VocabWord {
id: string
word: string
phonetic: string
meaningVi: string
topic: 'business' | 'office' | 'travel' | 'finance' | 'hr' | 'marketing'
example: string
}
### Supabase Edge Functions
```
POST /functions/v1/writing-check
Body : { content: string }
Return : {
score: string, // band score ước tính
grammar: string[], // lỗi ngữ pháp + gợi ý sửa
vocabulary: string[], // nhận xét từ vựng
structure: string, // nhận xét bố cục (tiếng Việt)
improved_version: string, // bài viết lại tốt hơn
summary: string // tổng nhận xét (tiếng Việt)
}
interface TestResult {
testId: string
part: number
score: number
total: number
duration: number
answers: { questionId: string; selected: string; correct: boolean }[]
completedAt: Date
}
interface WritingFeedback {
score: string
grammar: string[]
vocabulary: string[]
structure: string
improvedVersion: string
summary: string
}
// Phase 2+
interface User {
id: string
email: string
name: string
createdAt: Date
}
```
---
## Cấu trúc thư mục (React)
## Cấu trúc thư mục
```
src/
├── pages/
│ ├── Home.tsx Landing page + CTA
│ ├── ToeicPractice.tsx ← Chọn Part để luyện
│ ├── TestSession.tsx ← Làm bài (timer + câu hỏi)
│ ├── TestResult.tsx ← Kết quả + giải thích đáp án
│ ├── WritingChecker.tsx ← AI Writing Checker
│ └── Vocabulary.tsx ← Flashcard từ vựng
├── routes/
│ ├── index.tsx ← Trang chủ (/)
│ ├── toeic/
│ ├── index.tsx ← Chọn Part (/toeic)
│ ├── part.$partId.tsx ← Config số câu (/toeic/part/$partId)
│ ├── session.tsx ← Làm bài (/toeic/session)
│ └── result.tsx ← Kết quả + đáp án (/toeic/result)
│ ├── writing.tsx ← AI Writing Checker (/writing)
│ ├── vocab.tsx ← Flashcard (/vocab)
│ └── auth/ ← Phase 2
│ ├── login.tsx ← Đăng nhập (/auth/login)
│ └── register.tsx ← Đăng ký (/auth/register)
├── components/
│ ├── layout/
│ │ ├── Sidebar.tsx ← Desktop sidebar
│ │ └── BottomNav.tsx ← Mobile bottom nav
│ ├── QuestionCard.tsx
│ ├── FlashCard.tsx
│ ├── WritingFeedback.tsx
│ ├── ProgressBar.tsx
│ ├── ProgressRing.tsx
│ └── Timer.tsx
├── hooks/
│ ├── useQuestions.ts ← TanStack Query: fetch questions
│ ├── useVocab.ts ← TanStack Query: fetch vocab
│ └── useWritingCheck.ts ← TanStack Mutation: gọi Edge Function
├── store/
│ ├── testStore.ts ← Zustand: trạng thái bài thi
── vocabStore.ts ← Zustand: progress flashcard (sync localStorage)
── vocabStore.ts ← Zustand: flashcard progress (persist localStorage)
│ └── authStore.ts ← Zustand: user session (Phase 2)
├── hooks/
│ ├── useQuestions.ts ← TanStack Query
│ ├── useVocab.ts ← TanStack Query
│ └── useWritingCheck.ts ← TanStack Mutation → Edge Function
├── lib/
│ └── supabase.ts ← Supabase client init
└── utils/
└── rateLimiter.ts ← Rate limit AI Writing (3 lần/ngày/IP, localStorage)
└── rateLimiter.ts ← Rate limit 3 lần/ngày/IP (localStorage)
```
---
## Routes (TanStack Router)
## Supabase Edge Function — AI Writing Checker
```
/ ← Landing page
/toeic ← Chọn Part (17)
/toeic/part/$partId ← Config bài thi (số câu)
/toeic/session ← Làm bài
/toeic/result ← Kết quả + đáp án
/writing ← AI Writing Checker
/vocab ← Flashcard (filter theo topic)
```typescript
// supabase/functions/writing-check/index.ts
import { serve } from "https://deno.land/std/http/server.ts"
serve(async (req) => {
const { content } = await req.json()
const response = await fetch("https://open.bigmodel.cn/api/paas/v4/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${Deno.env.get("GLM_API_KEY")}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: "glm-4",
messages: [
{
role: "system",
content: `You are an expert English writing evaluator for TOEIC/IELTS.
Evaluate the writing and return ONLY valid JSON:
{
"score": "estimated band score",
"grammar": ["error + fix"],
"vocabulary": ["suggestion"],
"structure": "feedback in Vietnamese",
"improved_version": "rewritten version",
"summary": "overall feedback in Vietnamese"
}`
},
{ role: "user", content }
]
})
})
const data = await response.json()
const result = JSON.parse(data.choices[0].message.content)
return new Response(JSON.stringify(result), {
headers: { "Content-Type": "application/json" }
})
})
```
---
## Tính năng Phase 1 (đã chốt)
### ✅ 1. Luyện đề TOEIC
- Mini test theo từng Part (Part 1 → Part 7)
- Chọn số câu: 10 / 20 / full part
- Làm bài có đếm giờ
- Submit → xem điểm + đáp án đúng/sai + giải thích tiếng Việt
- Lịch sử kết quả lưu **localStorage** (Zustand persist)
- Thống kê điểm yếu theo Part
### ✅ 2. AI Writing Checker (Core Differentiator)
- Nhập bài writing tự do (TOEIC email, IELTS task, general)
- Gọi GLM qua Supabase Edge Function (API key an toàn)
- Feedback JSON: band score, lỗi ngữ pháp, từ vựng, cấu trúc, bài mẫu
- Rate limit: **3 lần / ngày / IP** (không cần login)
- Hiển thị feedback có highlight, dễ đọc trên mobile
### ✅ 3. Flashcard Từ vựng TOEIC
- 6 chủ đề: Business, Office, Travel, Finance, HR, Marketing
- Mỗi card: từ + phiên âm + nghĩa Việt + câu ví dụ
- Flip card animation
- Đánh dấu: Đã thuộc / Cần ôn
- Progress lưu localStorage (Zustand persist)
- Filter theo chủ đề
## Roadmap — 4 Phases
---
## Tính năng KHÔNG có ở Phase 1
### PHASE 1 — MVP (Hiện tại) 🚧
| Tính năng | Khi nào có |
**Mục tiêu**: Ra sản phẩm web dùng thử không cần login, validate market
**Stack**: React + Vite + TypeScript + TanStack + Zustand + Tailwind + Supabase + GLM
**Tính năng**:
- ✅ Luyện đề TOEIC mini test theo từng Part (Part 17)
- ✅ Chọn số câu: 10 / 20 / full part, có đếm giờ
- ✅ Submit → xem điểm + đáp án + giải thích tiếng Việt
- ✅ Lịch sử kết quả + thống kê điểm yếu theo Part (localStorage)
- ✅ AI Writing Checker (GLM, 3 lần/ngày/IP, không cần login)
- ✅ Flashcard từ vựng TOEIC (6 chủ đề, localStorage progress)
**Không có**:
- ❌ Auth / login
- ❌ Progress sync server
- ❌ Thanh toán
- ❌ Flutter / mobile app
- ❌ Full mock test
**Timeline**: 5 tuần
**Done khi**:
- ≥ 50 câu hỏi mỗi Part (Part 17)
- AI Writing Checker phản hồi < 5 giây
- Không lỗi hiển thị trên Chrome mobile
- 20+ người dùng thật đã dùng thử
- Không critical bug sau 1 tuần beta
---
### PHASE 2 — Auth & Progress
**Mục tiêu**: Giữ chân user, sync progress server-side, hiểu behavior trước khi monetize
**Trigger**: Phase 1 traction 200+ MAU hoặc feedback tích cực
**Stack thay đổi**:
- Migrate Supabase **NestJS + PostgreSQL native**
- Thêm **Redis** cho cache + session
**Guest Access (chưa đăng ký)**:
- Xem preview 1 bài test dạng read-only (thấy câu hỏi, không làm được)
- Không cho submit đáp án, không xem kết quả
- Hiện modal "Đăng để luyện thi" khi cố tương tác
- Flashcard: xem vài card đầu, bị chặn sau đó
- AI Writing: không dùng được, hiện CTA đăng
**Auth — Đăng ký**:
- Form: **Tên + Email + Password** (chỉ 3 field)
- Không xác thực email, không OTP, không confirm
- Đăng xong **redirect home luôn**
**Auth — Đăng nhập**:
- Email + Password
- Redirect về trang trước hoặc home
**Sau khi đăng nhập**:
- Làm bài không giới hạn
- Progress sync server-side: lịch sử thi, từ vựng, writing submissions
- Dashboard nhân: streak, biểu đồ điểm, điểm yếu theo Part
- AI Writing: 10 lần/ngày
**Không có ở Phase 2**:
- Xác thực email
- Quên mật khẩu
- Google / Zalo OAuth
- Flutter / mobile app
- Thanh toán / freemium
- Notification
---
### PHASE 3 — Xu System & Gamification ← Tiếp theo
**Mục tiêu**: Tạo thói quen học hàng ngày, giữ chân user bằng Xu economy + gamification
**Platform**: Web only (không Flutter phase này)
**Trigger**: Phase 2 user đăng , cần convert sang returning users
---
#### Xu Economy — Trung tâm của Phase 3
```
Học hàng ngày / xem ads → kiếm Xu
Xu → dùng tính năng premium
Hết Xu → xem ads thêm
(nạp tiền thật → Phase 4)
```
**Kiếm Xu (miễn phí)**:
| Hành động | Xu nhận |
|---|---|
| Đăng nhập / Auth | Phase 2 |
| Progress sync server | Phase 2 |
| Flutter mobile app | Phase 2 |
| NestJS backend | Phase 2 |
| Full TOEIC mock test | Phase 2 |
| Thanh toán | Phase 2 |
| Speaking / Pronunciation AI | Phase 3+ |
| Cộng đồng / Forum | Phase 3+ |
| Lớp học / Giáo viên | Phase 3+ |
| Đăng lần đầu (welcome bonus) | 50 Xu |
| Hoàn thành daily goal | 10 Xu |
| Streak milestone (7 / 30 / 100 ngày) | 20 / 50 / 100 Xu |
| Xem rewarded ads trên web | 5 Xu / video (tối đa 5 video/ngày) |
---
## Timeline Phase 1 (5 tuần)
| Tuần | Công việc |
**Dùng Xu**:
| Tính năng | Chi phí |
|---|---|
| **1** | Setup Supabase schema + seed đề TOEIC (≥50 câu/part) + React + Vite + Tailwind + TanStack + Zustand |
| **2** | UI luyện đề: chọn Part → làm bài → kết quả + giải thích |
| **3** | Supabase Edge Function + GLM API → AI Writing Checker + rate limit |
| **4** | Flashcard UI + Zustand persist + mobile polish + landing page |
| **5** | Bug fix + test mobile thật + deploy lên server + beta ~20 người |
| Streak freeze (bảo vệ 1 ngày) | 20 Xu |
| Thêm 5 lượt AI Writing (GLM-4.7) | 30 Xu |
| 1 lượt AI Writing cao cấp (GPT-4o) | 15 Xu |
| Mở thêm bài thi khi hết giới hạn free | 10 Xu |
> ⚠️ Chưa có nạp Xu bằng tiền thật ở Phase 3 — chỉ kiếm qua học + ads.
> Nạp tiền thật (VNPay/MoMo) → Phase 4.
---
## Definition of Done — Phase 1
- [ ] ≥ 50 câu hỏi mỗi Part (Part 17)
- [ ] AI Writing Checker phản hồi < 5 giây
- [ ] Không lỗi hiển thị trên Chrome mobile
- [ ] 20+ người dùng thật đã dùng thử
- [ ] Không critical bug sau 1 tuần beta
#### AI Model Tier
| Tier | Model | Free limit | Dùng Xu |
|---|---|---|---|
| Free | GLM-4 base | 3 lần/ngày | |
| Standard | GLM-4.7 | | 30 Xu / 5 lượt |
| Premium | GPT-4o / Claude | | 15 Xu / lượt |
---
## Rủi ro đã nhận diện
#### Gamification
- **Streak hàng ngày**: học ít nhất 1 bài/ngày giữ chuỗi
- **XP points**: mỗi bài thi / flashcard / writing check XP
- **Cấp độ**: Beginner Bronze Silver Gold Master (theo XP tích luỹ)
- **Streak freeze**: dùng Xu bảo vệ streak khi bận hook mạnh nhất
- **Weekly goal**: đặt mục tiêu tuần, hoàn thành badge + thưởng Xu
| Rủi ro | Mức độ | Xử |
|---|---|---|
| Content đề TOEIC chất lượng thấp (crawl) | 🔴 Cao | Crawl ít + clean kỹ, tự soạn dần để thay thế |
| GLM chấm writing không đủ tin cậy | 🟡 TB | Test prompt kỹ, fallback OpenAI-compatible nếu cần |
| Latency GLM từ VN cao | 🟡 TB | Benchmark thực tế tuần 3 |
| User mất progress (localStorage) | 🟡 TB | Chấp nhận MVP, auth Phase 2 giải quyết |
| Bản quyền đề TOEIC crawl | 🟡 TB | Dùng để seed nhanh, thay bằng nội dung tự soạn |
#### Leaderboard
- Bảng xếp hạng tuần theo XP (reset mỗi tuần, không tích luỹ)
- Hiện top 10 + vị trí của bản thân
- Chia sẻ kết quả n Facebook/Zalo viral loop tự nhiên
#### Nhắc nhở
- Browser push notification (web, không cần app)
- Giờ nhắc do user tự chọn
- Message nhân hoá: *"Streak 7 ngày của bạn sắp mất!"*
#### Lộ trình AI cá nhân hoá
- Phân tích kết quả thi suggest *"Tuần này tập trung Part 5 và 6"*
- Dashboard: *"Bạn yếu nhất ở Part 5 — luyện ngay"*
- Đặt ngày thi TOEIC đếm ngược + lịch ôn gợi ý
#### Web Ads
- Google AdSense: banner dưới trang + interstitial sau kết quả bài thi
- Rewarded video ads: xem để nhận Xu
- Không ads trong lúc đang làm bài
- User đủ Xu / premium không hiện ads (Phase 4)
---
#### DB Schema bổ sung
```sql
CREATE TABLE user_gamification (
user_id UUID REFERENCES users(id) PRIMARY KEY,
xp INT DEFAULT 0,
level TEXT DEFAULT 'beginner', -- beginner | bronze | silver | gold | master
streak INT DEFAULT 0,
longest_streak INT DEFAULT 0,
last_active DATE,
xu INT DEFAULT 50, -- welcome bonus
freeze_count INT DEFAULT 0
);
CREATE TABLE xu_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
type TEXT, -- earn_welcome | earn_daily | earn_streak | earn_ads | spend_freeze | spend_writing | spend_test
amount INT, -- dương = nhận, âm = tiêu
balance INT, -- số Xu sau giao dịch (để audit)
description TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE weekly_leaderboard (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
week_start DATE,
xp_earned INT DEFAULT 0,
rank INT
);
```
**Tech mới**:
- Google AdSense (banner + rewarded video)
- Browser Push Notification API
- Cron job: reset leaderboard mỗi tuần, check streak hàng ngày
**Không có ở Phase 3**:
- Nạp tiền thật (VNPay / MoMo) Phase 4
- Flutter / mobile app Phase 4
- Subscription / Premium plan Phase 4
**Timeline**: 56 tuần
---
### PHASE 4 — Speaking AI
**Mục tiêu**: Tăng differentiation, cover kỹ năng Speaking cho IELTS/TOEIC
**Trigger**: Phase 3 ổn định, doanh thu đều
**Tính năng**:
- AI Speaking Coach: record giọng AI chấm phát âm + so sánh native speaker
- Luyện IELTS Speaking Part 1/2/3 (mock interview với AI)
- Shadow reading: nghe lặp lại AI so sánh độ chính xác
- Pronunciation scoring điểm số progress chart
**Tech mới**:
- Speech-to-text: Whisper API hoặc Google Speech-to-Text
- Text-to-speech: native audio
- WebRTC / MediaRecorder API (web) + Flutter audio recording
---
### PHASE 5 — Full TOEIC Mock Test
**Mục tiêu**: Platform luyện TOEIC toàn diện chuẩn ETS
**Trigger**: Phase 4 xong, cần nội dung premium cao cấp hơn
**Tính năng**:
- Full TOEIC test chuẩn ETS: 200 câu, 120 phút
- Audio Listening chuẩn cho Part 14
- Auto-score: tính điểm 10990 theo bảng quy đổi ETS
- Phân tích chi tiết: điểm mạnh/yếu từng Part, so sánh lần trước
- Bộ đề theo năm: ETS 2023, 2024, Actual Test...
- Đếm ngược đến ngày thi + lịch ôn tập gợi ý
---
@@ -260,19 +513,49 @@ src/
| Quyết định | do |
|---|---|
| Không Auth Phase 1 | Giảm scope, user dùng thử không cần tạo tài khoản |
| Supabase thay NestJS tạm | Ra nhanh hơn 23 tuần, schema chuẩn để migrate sau |
| GLM thay OpenAI/Claude | Rẻ hơn đáng kể, API compatible, đ để test |
| Web-only, không Flutter | Tập trung 1 platform, Flutter Phase 2 reuse API |
| TanStack Query + Zustand | TanStack cho server state, Zustand cho client/local state |
| localStorage cho progress | Đủ cho MVP, không cần backend phức tạp |
| Không auth Phase 1 | Giảm scope, validate market trước |
| Email only Phase 2, không OAuth | Đơn giản nhất để build, OAuth Phase 4+ |
| Không xác thực email Phase 2 | MVP giảm friction đăng tối đa |
| Chỉ 3 field đăng (tên/email/pass) | Friction thấp nhất, đủ để identify user |
| Guest chỉ xem preview, không làm được | Buộc đăng để dùng, giúp thu thập user data |
| Không thanh toán Phase 2 | Hiểu behavior trước khi charge tiền |
| Không Flutter Phase 2 | Web đã responsive, Flutter Phase 3 khi traction |
| Coins tên "Xu" | Gần gũi user VN hơn "Credits" hay "Points" |
| Pay-as-you-go thay subscription | User VN ít cam kết dài hạn, mua theo nhu cầu dễ convert hơn |
| AI model tier theo Xu | Tạo upsell tự nhiên user thấy feedback tốt hơn khi dùng model cao hơn |
| Rewarded ads mobile, banner ads web | Phù hợp từng platform mobile chịu video, web chịu banner |
| Supabase tạm Phase 1 | Ra nhanh hơn 23 tuần, schema chuẩn để migrate sau |
| GLM thay OpenAI/Claude | Rẻ hơn, OpenAI-compatible, swap dễ |
| Desktop-first | Target TOEIC learner hay dùng máy tính |
| TanStack Query + Zustand | Server state tách biệt client state ràng |
| NestJS Phase 2 | Supabase đủ để validate, NestJS khi scale |
| Speaking AI Phase 4 | Cần infra + monetization ổn định trước khi làm realtime audio |
| Full mock test Phase 5 | Cần content team + audio, không phải tech problem |
---
## Conventions
- **Ngôn ngữ**: Tiếng Việt cho UI người dùng, English cho code/comments
- **Ngôn ngữ**: Tiếng Việt cho UI người dùng, English cho code/comments/type names
- **Giải thích đáp án**: Luôn bằng tiếng Việt
- **AI feedback**: Mix Việt-Anh (nhận xét tổng thể tiếng Việt, dụ sửa tiếng Anh)
- **Mobile-first**: Test trên màn hình 375px trước, desktop sau
- **YAGNI / KISS**: Không build thứ chưa cần, Phase 1 xong mới nghĩ Phase 2
- **AI feedback**: Nhận xét tổng thể tiếng Việt, dụ sửa tiếng Anh
- **Desktop-first**: Design test trên 1280px trước, mobile sau
- **YAGNI / KISS**: Không build thứ chưa cần, từng Phase giải quyết từng vấn đề
- **Schema chuẩn ngay từ đầu**: dùng Supabase, PostgreSQL schema phải production-ready
- **Coins = "Xu"**: Dùng nhất quán trong code lẫn UI
---
## Rủi ro đã nhận diện
| Rủi ro | Mức độ | Xử |
|---|---|---|
| Content đề TOEIC chất lượng thấp (crawl) | 🔴 Cao | Crawl ít + clean kỹ, tự soạn dần thay thế |
| GLM chấm writing không đủ tin cậy | 🟡 TB | Test prompt kỹ, fallback OpenAI nếu cần |
| Latency GLM từ VN cao | 🟡 TB | Benchmark thực tế, cache response nếu cần |
| User mất progress (localStorage Phase 1) | 🟡 TB | Chấp nhận Phase 1, auth Phase 2 giải quyết |
| Bản quyền đề TOEIC crawl | 🟡 TB | Seed nhanh, thay bằng nội dung tự soạn dần |
| Supabase free tier limit | 🟢 Thấp | 500MB đủ Phase 1, migrate trước khi hit limit |
| AdSense bị block (adblocker) | 🟡 TB | Rewarded ads mobile lại, không phụ thuộc 1 nguồn |
| VNPay/MoMo integration phức tạp | 🟡 TB | Dùng payment gateway trung gian (Stripe VN, PayOS) |
| Audio quality Phase 5 | 🟡 TB | Budget cho studio recording hoặc TTS premium |

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# ============================================================
# Stage 1 — Build
# Node 22 Alpine: smallest image with full npm support
# ============================================================
FROM node:22-alpine AS builder
WORKDIR /app
# Install dependencies first (layer cache — only re-runs if package.json changes)
COPY package.json package-lock.json ./
RUN npm ci
# Copy source and build — no VITE_* args needed at build time.
# Supabase keys are injected at runtime via docker/entrypoint.sh → window.__ENV__
COPY . .
RUN npm run build
# ============================================================
# Stage 2 — Serve
# Nginx Alpine: ~25MB final image
# ============================================================
FROM nginx:alpine AS runner
# Replace default nginx config with SPA config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built static files from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Entrypoint generates env.js from runtime env vars before starting nginx
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 80
CMD ["/entrypoint.sh"]

25
components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

83
docker-build.sh Executable file
View File

@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Build multi-platform Docker image (linux/amd64 + linux/arm64)
# Usage:
# ./docker-build.sh → build + load locally (single platform)
# ./docker-build.sh --push → build + push to Docker Hub
set -euo pipefail
# ── Config ────────────────────────────────────────────────────
IMAGE_NAME="renolation/english-toeic"
IMAGE_TAG="${IMAGE_TAG:-latest}"
REGISTRY="${REGISTRY:-}"
PLATFORMS="linux/amd64,linux/arm64"
BUILDER_NAME="multiarch-builder"
ENV_FILE=".env"
# ── Load env vars from .env ───────────────────────────────────
if [[ ! -f "$ENV_FILE" ]]; then
echo "$ENV_FILE not found. Copy .env.example and fill in values."
exit 1
fi
# Load all vars from .env (set -a auto-exports every assignment)
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
: "${VITE_SUPABASE_URL:?VITE_SUPABASE_URL is required in .env}"
: "${VITE_SUPABASE_ANON_KEY:?VITE_SUPABASE_ANON_KEY is required in .env}"
FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
# ── Parse flags ───────────────────────────────────────────────
PUSH=false
for arg in "$@"; do
case $arg in
--push) PUSH=true ;;
esac
done
echo "🔧 Image : $FULL_IMAGE"
echo "🖥️ Platforms: $PLATFORMS"
echo ""
# ── Ensure buildx builder exists ─────────────────────────────
if ! docker buildx inspect "$BUILDER_NAME" &>/dev/null; then
echo "📦 Creating buildx builder: $BUILDER_NAME"
docker buildx create --name "$BUILDER_NAME" --driver docker-container --bootstrap
fi
docker buildx use "$BUILDER_NAME"
# ── Build ─────────────────────────────────────────────────────
BUILD_ARGS=(
--platform "$PLATFORMS"
--build-arg "VITE_SUPABASE_URL=${VITE_SUPABASE_URL}"
--build-arg "VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}"
--build-arg "VITE_SUPABASE_PUBLISHABLE_KEY=${VITE_SUPABASE_PUBLISHABLE_KEY:-}"
--tag "$FULL_IMAGE"
--file Dockerfile
)
if $PUSH; then
echo "🚀 Building and pushing to registry..."
docker buildx build "${BUILD_ARGS[@]}" --push .
echo ""
echo "✅ Pushed: $FULL_IMAGE"
else
# --load only works for single platform; build amd64 locally by default
echo "🏗️ Building for local use (linux/amd64)..."
docker buildx build \
--platform linux/amd64 \
--build-arg "VITE_SUPABASE_URL=${VITE_SUPABASE_URL}" \
--build-arg "VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}" \
--build-arg "VITE_SUPABASE_PUBLISHABLE_KEY=${VITE_SUPABASE_PUBLISHABLE_KEY:-}" \
--tag "$FULL_IMAGE" \
--file Dockerfile \
--load \
.
echo ""
echo "✅ Built locally: $FULL_IMAGE"
echo " Run with: docker compose up (or) docker run -p 3000:80 $FULL_IMAGE"
fi

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
image: renolation/english-toeic:latest
environment:
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
- VITE_SUPABASE_PUBLISHABLE_KEY=${VITE_SUPABASE_PUBLISHABLE_KEY}
ports:
- "${APP_PORT:-3000}:80"
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/health"]
interval: 30s
timeout: 5s
retries: 3

10
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,10 @@
#!/bin/sh
# Generate runtime env.js from container environment variables
cat > /usr/share/nginx/html/env.js <<EOF
window.__ENV__ = {
VITE_SUPABASE_URL: "${VITE_SUPABASE_URL}",
VITE_SUPABASE_ANON_KEY: "${VITE_SUPABASE_ANON_KEY}",
VITE_SUPABASE_PUBLISHABLE_KEY: "${VITE_SUPABASE_PUBLISHABLE_KEY}"
};
EOF
exec nginx -g 'daemon off;'

28
eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from "@eslint/js"
import globals from "globals"
import reactHooks from "eslint-plugin-react-hooks"
import reactRefresh from "eslint-plugin-react-refresh"
import tseslint from "typescript-eslint"
export default tseslint.config(
{ ignores: ["dist", "src/routeTree.gen.ts", ".claude", ".opencode"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
)

19
index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<script src="/env.js"></script>
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EnglishAI — Luyện TOEIC thông minh</title>
<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=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT,WONK@0,9..144,300..700,0..100,0..1;1,9..144,300..700,0..100,0..1&family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
nginx.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
gzip_min_length 1024;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback — all routes serve index.html
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "ok";
add_header Content-Type text/plain;
}
}

45
package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "english-learning-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
"@supabase/supabase-js": "^2.103.0",
"@tanstack/react-query": "^5.99.0",
"@tanstack/react-router": "^1.168.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"shadcn": "^4.2.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/router-plugin": "^1.167.15",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.1",
"vite": "^8.0.8"
}
}

1
public/env.js Normal file
View File

@@ -0,0 +1 @@
window.__ENV__ = {};

View File

@@ -0,0 +1,37 @@
interface CircularProgressProps {
percent: number
size?: number
strokeWidth?: number
color?: string
}
/** SVG circular progress ring with centered percentage label. */
export function CircularProgress({
percent,
size = 44,
strokeWidth = 3.5,
color = '#2563EB',
}: CircularProgressProps) {
const cx = size / 2
const radius = cx - strokeWidth
const circumference = 2 * Math.PI * radius
const offset = circumference - (Math.min(percent, 100) / 100) * circumference
return (
<div className="relative flex items-center justify-center flex-shrink-0" style={{ width: size, height: size }}>
<svg className="-rotate-90" width={size} height={size}>
<circle cx={cx} cy={cx} r={radius} fill="none" stroke="#e2e8f0" strokeWidth={strokeWidth} />
<circle
cx={cx} cy={cx} r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
/>
</svg>
<span className="absolute text-[10px] font-bold text-slate-700">{percent}%</span>
</div>
)
}

View File

@@ -0,0 +1,26 @@
interface ProgressBarProps {
value?: number
max?: number
label?: string
}
export function ProgressBar({ value = 0, max = 100, label }: ProgressBarProps) {
const pct = Math.min(100, Math.round((value / max) * 100))
return (
<div className="space-y-1">
{label && (
<div className="flex justify-between text-sm text-gray-600">
<span>{label}</span>
<span>{value}/{max}</span>
</div>
)}
<div className="h-2 w-full rounded-full bg-gray-200">
<div
className="h-2 rounded-full bg-blue-500 transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
}

113
src/components/UserMenu.tsx Normal file
View File

@@ -0,0 +1,113 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useAuthStore } from '@/store/auth-store'
import { useUser } from '@/hooks/use-auth'
import { useAuthModalStore } from '@/store/auth-modal-store'
/** Avatar circle with first letter of name, deterministic color */
function Avatar({ name }: { name: string }) {
const colors = ['bg-blue-600', 'bg-green-600', 'bg-violet-600', 'bg-rose-600', 'bg-amber-600']
const color = colors[name.charCodeAt(0) % colors.length]
return (
<div className={`w-8 h-8 rounded-full ${color} flex items-center justify-center text-white text-sm font-bold flex-shrink-0`}>
{name.charAt(0).toUpperCase()}
</div>
)
}
export function UserMenu() {
const user = useUser()
const logout = useAuthStore((s) => s.logout)
const openModal = useAuthModalStore((s) => s.open)
const navigate = useNavigate()
const [dropdownOpen, setDropdownOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
// Close on outside click
useEffect(() => {
if (!dropdownOpen) return
function handleClick(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setDropdownOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [dropdownOpen])
async function handleLogout() {
setDropdownOpen(false)
await logout()
navigate({ to: '/' })
}
if (!user) {
return (
<div className="flex items-center gap-2">
<button
onClick={() => openModal('login')}
className="px-3.5 py-1.5 text-sm font-semibold text-slate-600 border border-slate-300 rounded-xl hover:bg-slate-50 transition-colors"
>
Đăng nhập
</button>
<button
onClick={() => openModal('register')}
className="px-3.5 py-1.5 text-sm font-semibold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition-colors"
>
Đăng
</button>
</div>
)
}
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setDropdownOpen((o) => !o)}
className="flex items-center gap-2 px-2 py-1 rounded-xl hover:bg-slate-100 transition-colors"
>
<Avatar name={user.name} />
<span className="text-sm font-semibold text-slate-700 max-w-28 truncate hidden sm:block">
{user.name}
</span>
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 16 }}>
expand_more
</span>
</button>
{dropdownOpen && (
<div className="absolute right-0 top-full mt-2 w-52 bg-white rounded-2xl shadow-lg border border-slate-200 py-1.5 z-50">
<div className="px-4 py-2 border-b border-slate-100 mb-1">
<div className="text-sm font-semibold text-slate-800 truncate">{user.name}</div>
<div className="text-xs text-slate-400 truncate">{user.email}</div>
</div>
<button
onClick={() => { setDropdownOpen(false); alert('Coming soon!') }}
className="w-full flex items-center gap-2.5 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50 transition-colors"
>
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 18 }}>history</span>
Lịch sử bài thi
</button>
<button
onClick={() => { setDropdownOpen(false); alert('Coming soon!') }}
className="w-full flex items-center gap-2.5 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50 transition-colors"
>
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 18 }}>edit_note</span>
Lịch sử writing
</button>
<div className="border-t border-slate-100 mt-1 pt-1">
<button
onClick={handleLogout}
className="w-full flex items-center gap-2.5 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<span className="material-symbols-outlined text-red-400" style={{ fontSize: 18 }}>logout</span>
Đăng xuất
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,81 @@
import { useRouterState } from '@tanstack/react-router'
import { useTestStore } from '@/store/test-store'
import { UserMenu } from '@/components/UserMenu'
const ROUTE_TITLES: Record<string, { eyebrow: string; title: string; accent?: string }> = {
'/': { eyebrow: 'Học TOEIC cùng AI', title: 'Trang chủ' },
'/archivement': { eyebrow: 'Thành tích của bạn', title: 'Tôi học', accent: 'học' },
'/toeic': { eyebrow: 'Luyện đề', title: 'TOEIC Mock Tests', accent: 'Mock' },
'/writing': { eyebrow: 'AI Coach', title: 'Chấm Writing', accent: 'Writing' },
'/flash-card': { eyebrow: 'Từ vựng TOEIC', title: 'Flash Card', accent: 'Card' },
'/settings': { eyebrow: 'Tuỳ chỉnh', title: 'Cài đặt' },
}
function matchRouteLabel(pathname: string) {
if (ROUTE_TITLES[pathname]) return ROUTE_TITLES[pathname]
const keys = Object.keys(ROUTE_TITLES).sort((a, b) => b.length - a.length)
for (const k of keys) {
if (k !== '/' && pathname.startsWith(k)) return ROUTE_TITLES[k]
}
return { eyebrow: 'EnglishAI', title: 'EnglishAI' }
}
export function AppHeader() {
const { location } = useRouterState()
const { testName, parts, answers } = useTestStore()
const pathname = location.pathname
// In-session mode: show test progress instead of route title
if (pathname === '/toeic/session') {
const totalQuestions = parts.reduce((sum, p) => sum + p.questions.length, 0)
const answered = Object.values(answers).filter((a) => a !== null).length
return (
<header
className="fixed top-0 right-0 left-0 lg:left-60 h-16 z-40 flex items-center justify-between px-6 backdrop-blur-md"
style={{
background: 'color-mix(in oklab, var(--at-paper) 88%, transparent)',
borderBottom: '1px solid var(--at-line)',
}}
>
<div>
<div className="at-eyebrow" style={{ fontSize: 10, marginBottom: 2 }}>Phiên thi</div>
<div className="at-serif text-[15px]" style={{ color: 'var(--at-ink)', fontWeight: 500, letterSpacing: '-0.01em' }}>
{testName} · <i className="italic" style={{ color: 'var(--at-brand)' }}>{answered}/{totalQuestions}</i> câu
</div>
</div>
<UserMenu />
</header>
)
}
const { eyebrow, title, accent } = matchRouteLabel(pathname)
const renderTitle = () => {
if (!accent || !title.includes(accent)) return title
const [before, after] = title.split(accent)
return (
<>
{before}
<i className="italic" style={{ color: 'var(--at-brand)' }}>{accent}</i>
{after}
</>
)
}
return (
<header
className="fixed top-0 right-0 left-0 lg:left-60 h-16 z-40 flex items-center justify-between px-6 backdrop-blur-md"
style={{
background: 'color-mix(in oklab, var(--at-paper) 88%, transparent)',
borderBottom: '1px solid var(--at-line)',
}}
>
<div>
<div className="at-eyebrow" style={{ fontSize: 10, marginBottom: 2 }}>{eyebrow}</div>
<div className="at-serif text-[15px]" style={{ color: 'var(--at-ink)', fontWeight: 500, letterSpacing: '-0.01em' }}>
{renderTitle()}
</div>
</div>
<UserMenu />
</header>
)
}

View File

@@ -0,0 +1,42 @@
import { Link, useRouterState } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
const NAV_ITEMS = [
{ to: '/', label: 'Home', icon: 'home', matchPrefix: '/', exact: true },
{ to: '/archivement', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/archivement', exact: false },
{ to: '/toeic', label: 'Luyện đề', icon: 'assignment', matchPrefix: '/toeic', exact: false },
{ to: '/writing', label: 'Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false },
{ to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false },
]
function isActive(pathname: string, prefix: string, exact: boolean) {
return exact ? pathname === prefix : pathname.startsWith(prefix)
}
export function MobileNav() {
const { location } = useRouterState()
const pathname = location.pathname
return (
<nav className="fixed bottom-0 inset-x-0 lg:hidden bg-white border-t border-slate-200 z-50 flex safe-area-inset-bottom">
{NAV_ITEMS.map((item) => {
const active = isActive(pathname, item.matchPrefix, item.exact)
return (
<Link
key={item.to}
to={item.to}
className={cn(
'flex-1 flex flex-col items-center justify-center gap-0.5 py-2 min-h-[56px] text-[11px] font-medium transition-colors',
active ? 'text-blue-600' : 'text-slate-400',
)}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
{item.icon}
</span>
{item.label}
</Link>
)
})}
</nav>
)
}

View File

@@ -0,0 +1,137 @@
import { Link, useRouterState } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth-store'
import { useAuthModalStore } from '@/store/auth-modal-store'
const NAV_ITEMS = [
{ to: '/', label: 'Trang chủ', icon: 'home', matchPrefix: '/', exact: true },
{ to: '/archivement', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/archivement', exact: false },
{ to: '/toeic', label: 'Luyện đề TOEIC', icon: 'assignment', matchPrefix: '/toeic', exact: false },
{ to: '/writing', label: 'AI Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false },
{ to: '/flash-card', label: 'Flash Card', icon: 'menu_book', matchPrefix: '/flash-card', exact: false },
{ to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false },
]
function isActive(pathname: string, prefix: string, exact: boolean) {
return exact ? pathname === prefix : pathname.startsWith(prefix)
}
export function Sidebar() {
const { location } = useRouterState()
const pathname = location.pathname
const user = useAuthStore((s) => s.user)
const openModal = useAuthModalStore((s) => s.open)
return (
<aside
className="hidden lg:flex fixed inset-y-0 left-0 w-60 flex-col z-50"
style={{
background: 'var(--at-paper)',
borderRight: '1px solid var(--at-line)',
}}
>
{/* Brand */}
<div className="px-5 pt-7 pb-9 flex items-start gap-2.5">
<div
className="w-[34px] h-[34px] rounded-[10px] grid place-items-center flex-shrink-0 at-serif italic"
style={{
background: 'var(--at-ink)',
color: 'var(--at-paper)',
fontSize: 20,
fontWeight: 500,
letterSpacing: '-0.02em',
}}
>
E
</div>
<div>
<div className="at-serif" style={{ fontSize: 18, fontWeight: 500, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-ink)' }}>
EnglishAI
</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--at-mute)', letterSpacing: '0.14em', textTransform: 'uppercase', marginTop: 2 }}>
TOEIC Curator
</div>
</div>
</div>
{/* Nav */}
<nav className="flex-1 px-4 overflow-y-auto">
<div className="px-3 pb-2" style={{ fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase', color: 'var(--at-mute-2)', fontWeight: 600 }}>
Học tập
</div>
<div className="flex flex-col gap-0.5">
{NAV_ITEMS.map((item) => {
const active = isActive(pathname, item.matchPrefix, item.exact)
return (
<Link
key={item.to}
to={item.to}
className={cn(
'relative flex items-center gap-3 px-3 py-2.5 rounded-[10px] text-[13.5px] font-medium transition-colors',
)}
style={{
background: active ? 'var(--at-line-2)' : 'transparent',
color: active ? 'var(--at-ink)' : 'var(--at-ink-2)',
}}
>
{active && (
<span
className="absolute top-2 bottom-2 rounded-full"
style={{ left: -18, width: 2, background: 'var(--at-brand)' }}
/>
)}
<span
className="material-symbols-outlined"
style={{ fontSize: 20, color: active ? 'var(--at-brand)' : 'var(--at-mute)' }}
>
{item.icon}
</span>
{item.label}
</Link>
)
})}
</div>
</nav>
{/* User */}
<div className="px-3 py-4">
{user ? (
<div
className="flex items-center gap-2.5 px-2.5 py-2.5 rounded-xl"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div
className="w-9 h-9 rounded-[10px] grid place-items-center flex-shrink-0 at-serif italic"
style={{
background: 'linear-gradient(135deg, #F0E6D8, #E5D4B7)',
color: 'var(--at-ink)',
fontSize: 16,
fontWeight: 500,
}}
>
{user.name.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<div className="text-[13px] font-semibold truncate" style={{ color: 'var(--at-ink)' }}>{user.name}</div>
<div className="text-[11px] truncate" style={{ color: 'var(--at-mute)' }}>{user.email}</div>
</div>
</div>
) : (
<button
onClick={() => openModal('login')}
className="w-full flex items-center gap-2.5 px-2.5 py-2.5 rounded-xl hover:bg-[var(--at-line-2)] transition-colors"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div className="w-9 h-9 rounded-[10px] grid place-items-center flex-shrink-0" style={{ background: 'var(--at-line-2)' }}>
<span className="material-symbols-outlined" style={{ fontSize: 18, color: 'var(--at-mute)' }}>person</span>
</div>
<div className="min-w-0 text-left">
<div className="text-[13px] font-semibold" style={{ color: 'var(--at-ink-2)' }}>Khách</div>
<div className="text-[11px] font-medium at-serif italic" style={{ color: 'var(--at-brand)' }}>Đăng nhập </div>
</div>
</button>
)}
</div>
</aside>
)
}

View File

@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,96 @@
import { useEffect } from 'react'
import { useAuthModalStore } from '@/store/auth-modal-store'
import { LoginForm } from './LoginForm'
import { RegisterForm } from './RegisterForm'
export function AuthModal() {
const { isOpen, mode, open, close } = useAuthModalStore()
// Close on Escape key
useEffect(() => {
if (!isOpen) return
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') close()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isOpen, close])
// Prevent body scroll when open
useEffect(() => {
document.body.style.overflow = isOpen ? 'hidden' : ''
return () => { document.body.style.overflow = '' }
}, [isOpen])
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-60 flex items-center justify-center p-4"
style={{ zIndex: 60 }}
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50"
onClick={close}
/>
{/* Card */}
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6">
{/* Close button */}
<button
onClick={close}
className="absolute top-4 right-4 text-slate-400 hover:text-slate-600 transition-colors"
aria-label="Đóng"
>
<span className="material-symbols-outlined text-xl">close</span>
</button>
{/* Header */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-4">
<span className="material-symbols-outlined text-blue-600">school</span>
<span className="font-bold text-slate-800">TOEIC Luyện thi</span>
</div>
{/* Tab toggle */}
<div className="flex bg-slate-100 rounded-xl p-1">
<button
onClick={() => open('login')}
className={`flex-1 py-2 text-sm font-semibold rounded-lg transition-all ${
mode === 'login'
? 'bg-white text-blue-600 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
Đăng nhập
</button>
<button
onClick={() => open('register')}
className={`flex-1 py-2 text-sm font-semibold rounded-lg transition-all ${
mode === 'register'
? 'bg-white text-blue-600 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
Đăng
</button>
</div>
</div>
{/* Form */}
{mode === 'login' ? (
<LoginForm
onSuccess={close}
onSwitchToRegister={() => open('register')}
/>
) : (
<RegisterForm
onSuccess={close}
onSwitchToLogin={() => open('login')}
/>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,82 @@
import { useState } from 'react'
import { useAuthStore } from '@/store/auth-store'
interface LoginFormProps {
onSuccess?: () => void
onSwitchToRegister?: () => void
}
export function LoginForm({ onSuccess, onSwitchToRegister }: LoginFormProps) {
const login = useAuthStore((s) => s.login)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setIsSubmitting(true)
try {
await login(email, password)
onSuccess?.()
} catch (err) {
setError((err as Error).message)
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Email</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Mật khẩu</label>
<input
type="password"
required
minLength={6}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Tối thiểu 6 ký tự"
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2.5 bg-blue-600 text-white text-sm font-semibold rounded-xl hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? 'Đang đăng nhập...' : 'Đăng nhập'}
</button>
{onSwitchToRegister && (
<p className="text-center text-sm text-slate-500">
Chưa tài khoản?{' '}
<button
type="button"
onClick={onSwitchToRegister}
className="text-blue-600 font-medium hover:underline"
>
Đăng ngay
</button>
</p>
)}
</form>
)
}

View File

@@ -0,0 +1,34 @@
import { useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useUser } from '@/hooks/use-auth'
import { LoginForm } from './LoginForm'
export function LoginPage() {
const user = useUser()
const navigate = useNavigate()
useEffect(() => {
if (user) navigate({ to: '/' })
}, [user, navigate])
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-slate-50">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-blue-600 text-3xl">school</span>
<span className="text-2xl font-bold text-slate-800">TOEIC Luyện thi</span>
</div>
<h1 className="text-xl font-semibold text-slate-700">Đăng nhập tài khoản</h1>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
<LoginForm
onSuccess={() => navigate({ to: '/' })}
onSwitchToRegister={() => navigate({ to: '/auth/register' })}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,94 @@
import { useState } from 'react'
import { useAuthStore } from '@/store/auth-store'
interface RegisterFormProps {
onSuccess?: () => void
onSwitchToLogin?: () => void
}
export function RegisterForm({ onSuccess, onSwitchToLogin }: RegisterFormProps) {
const register = useAuthStore((s) => s.register)
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setIsSubmitting(true)
try {
await register(name, email, password)
onSuccess?.()
} catch (err) {
setError((err as Error).message)
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Tên của bạn</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Nguyễn Văn A"
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Email</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Mật khẩu</label>
<input
type="password"
required
minLength={6}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Tối thiểu 6 ký tự"
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2.5 bg-blue-600 text-white text-sm font-semibold rounded-xl hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? 'Đang đăng ký...' : 'Đăng ký'}
</button>
{onSwitchToLogin && (
<p className="text-center text-sm text-slate-500">
Đã tài khoản?{' '}
<button
type="button"
onClick={onSwitchToLogin}
className="text-blue-600 font-medium hover:underline"
>
Đăng nhập
</button>
</p>
)}
</form>
)
}

View File

@@ -0,0 +1,35 @@
import { useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useUser } from '@/hooks/use-auth'
import { RegisterForm } from './RegisterForm'
export function RegisterPage() {
const user = useUser()
const navigate = useNavigate()
useEffect(() => {
if (user) navigate({ to: '/' })
}, [user, navigate])
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-slate-50">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-blue-600 text-3xl">school</span>
<span className="text-2xl font-bold text-slate-800">TOEIC Luyện thi</span>
</div>
<h1 className="text-xl font-semibold text-slate-700">Tạo tài khoản miễn phí</h1>
<p className="text-sm text-slate-500 mt-1">Không cần xác nhận email dùng ngay lập tức</p>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
<RegisterForm
onSuccess={() => navigate({ to: '/' })}
onSwitchToLogin={() => navigate({ to: '/auth/login' })}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,603 @@
import { Link } from '@tanstack/react-router'
import { useAuthStore } from '@/store/auth-store'
import { useAuthModalStore } from '@/store/auth-modal-store'
import { useGamification, useLeaderboard } from '@/hooks/use-gamification'
import { XP_REWARDS } from '@/lib/gamification-service'
const LEVEL_LABEL: Record<string, string> = {
beginner: 'Beginner',
bronze: 'Bronze',
silver: 'Silver',
gold: 'Gold',
master: 'Master',
}
function calcNumericLevel(xp: number) {
return Math.max(1, Math.floor(xp / 100))
}
function xpForNext(xp: number) {
return (Math.floor(xp / 100) + 1) * 100
}
const EARN_ITEMS = [
{ label: 'Hoàn thành mục tiêu ngày', amt: 10 },
{ label: 'Mốc chuỗi (Streak)', amt: 20 },
{ label: 'Xem quảng cáo', amt: 5 },
{ label: 'Chia sẻ với bạn bè', amt: 15 },
]
const SPEND_ITEMS = [
{ label: 'Streak Freeze', amt: 20, desc: 'Giữ streak 1 ngày nghỉ' },
{ label: 'AI Writing Feedback', amt: 30, desc: 'Phân tích bài viết sâu' },
{ label: 'Bộ thẻ Premium', amt: 50, desc: 'Mở khoá toàn bộ chủ đề' },
{ label: 'Đổi theme hiếm', amt: 40, desc: 'Giao diện Atelier Noir' },
]
type Badge = { id: string; name: string; desc: string; earned: boolean; progress?: number; icon: string; color: string }
const BADGES: Badge[] = [
{ id: 'b1', name: 'Khởi hành', desc: 'Học ngày đầu tiên', earned: true, icon: 'auto_awesome', color: 'var(--at-brand)' },
{ id: 'b2', name: 'Một tuần', desc: '7 ngày liên tiếp', earned: false, progress: 40, icon: 'local_fire_department', color: 'var(--at-streak)' },
{ id: 'b3', name: 'Bền bỉ', desc: '30 ngày liên tiếp', earned: false, progress: 10, icon: 'local_fire_department', color: 'var(--at-warm)' },
{ id: 'b4', name: 'Mọt sách', desc: 'Thuộc 100 từ vựng', earned: false, progress: 30, icon: 'style', color: '#8B5CF6' },
{ id: 'b5', name: 'Nhà ngôn ngữ', desc: 'Thuộc 500 từ vựng', earned: false, progress: 10, icon: 'style', color: '#8B5CF6' },
{ id: 'b6', name: 'Điểm số vàng', desc: 'Đạt 800+ TOEIC', earned: false, progress: 20, icon: 'emoji_events', color: 'var(--at-good)' },
{ id: 'b7', name: 'Thí sinh', desc: 'Hoàn thành 10 đề full', earned: false, progress: 0, icon: 'fact_check', color: 'var(--at-brand)' },
{ id: 'b8', name: 'Cây viết', desc: 'Gửi 20 bài AI Writing', earned: false, progress: 10, icon: 'edit_note', color: 'var(--at-good)' },
]
function Coin({ size = 14 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'inline-block', verticalAlign: '-2px' }}>
<circle cx="12" cy="12" r="10" fill="#F5B94A" stroke="#C9902F" strokeWidth="1.2" />
<circle cx="12" cy="12" r="7" fill="none" stroke="#C9902F" strokeWidth="0.8" opacity="0.6" />
<text x="12" y="15.5" textAnchor="middle" fontFamily="var(--at-serif)" fontSize="9.5" fontWeight="700" fill="#7B5210">XU</text>
</svg>
)
}
export function Dashboard() {
const user = useAuthStore((s) => s.user)
const openModal = useAuthModalStore((s) => s.open)
const { data: gam, isLoading } = useGamification()
const { data: leaderboard } = useLeaderboard()
if (!user) {
return (
<div className="px-4 lg:px-6 py-20 max-w-6xl mx-auto flex flex-col items-center text-center gap-4">
<div className="at-serif italic text-5xl" style={{ color: 'var(--at-mute-2)' }}>Thành tích</div>
<p className="max-w-sm" style={{ color: 'var(--at-mute)' }}>
Đăng nhập đ xem streak, XP, Xu bảng xếp hạng của bạn.
</p>
<button
onClick={() => openModal('login')}
className="mt-2 px-6 py-2.5 rounded-xl font-semibold text-sm hover:opacity-90 transition"
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
>
Đăng nhập
</button>
</div>
)
}
const xu = gam?.xu ?? 50
const streak = gam?.streak ?? 0
const xp = gam?.xp ?? 0
const levelLabel = LEVEL_LABEL[gam?.level ?? 'beginner']
const numericLevel = calcNumericLevel(xp)
const nextLevelXp = xpForNext(xp)
const xpIntoLevel = xp - numericLevel * 100
const levelPct = Math.round((xpIntoLevel / 100) * 100)
const xpLeft = nextLevelXp - xp
// Week metrics
const userLbRow = leaderboard?.find((r) => r.userId === user.id)
const weeklyXp = userLbRow?.xpEarned ?? 0
const weeklyCompleted = Math.min(Math.floor(weeklyXp / XP_REWARDS.test), 5)
const weekGoalTotal = 5
// 7-day history mock pattern — actual tracking would need daily_activity table
const todayDayIdx = (new Date().getDay() + 6) % 7 // Mon=0..Sun=6
const history = ['T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'CN'].map((d, i) => {
if (i === todayDayIdx) return { d, state: 'today' as const }
if (i < todayDayIdx) return { d, state: i < weeklyCompleted ? 'done' as const : 'empty' as const }
return { d, state: 'future' as const }
})
// Leaderboard display (top 5, highlight self)
const board = (leaderboard ?? []).slice(0, 5).map((row, idx) => ({
rank: idx + 1,
name: row.userId === user.id ? `${user.name} (Bạn)` : `User ${row.userId.slice(0, 6)}`,
xp: row.xpEarned,
you: row.userId === user.id,
avatar: (row.userId === user.id ? user.name : 'U').charAt(0).toUpperCase(),
}))
return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
{/* Editorial head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div>
<div className="at-eyebrow mb-3">Thành tích</div>
<h1 className="at-title text-4xl lg:text-[44px]">
Bảng <i>thành tích</i>
</h1>
<p className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
Xin chào, <b style={{ color: 'var(--at-ink)' }}>{user.name}</b> tiếp tục chuỗi học tập nhé!
</p>
</div>
<div className="flex gap-2.5 flex-shrink-0">
<button
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13.5px] font-semibold hover:bg-[var(--at-line-2)] transition-colors"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 15 }}>share</span>
Chia sẻ
</button>
<Link
to="/toeic"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-[13.5px] font-semibold hover:opacity-90 transition-opacity"
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 15 }}>play_arrow</span>
Học tiếp
</Link>
</div>
</div>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-5">
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-2xl h-32 animate-pulse" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }} />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-5" style={{ gridTemplateColumns: '1fr 1.2fr 1fr' }}>
{/* XU */}
<div
className="rounded-2xl p-5 relative overflow-hidden"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div
className="absolute inset-0 pointer-events-none"
style={{ background: 'radial-gradient(120% 80% at 100% 0%, color-mix(in oklab, #F5B94A 18%, transparent) 0%, transparent 55%)' }}
/>
<div className="relative">
<div className="at-eyebrow" style={{ color: '#B88432' }}>Số Xu</div>
<div className="flex items-baseline gap-2.5 mt-2">
<div className="at-serif" style={{ fontSize: 54, fontWeight: 400, letterSpacing: '-0.03em', lineHeight: 0.95 }}>
{xu}
</div>
<Coin size={26} />
</div>
<div className="text-[12.5px] mt-2.5 max-w-[200px]" style={{ color: 'var(--at-mute)' }}>
Dùng đ mở tính năng premium, freeze streak hoặc đi giao diện.
</div>
</div>
</div>
{/* STREAK (featured, ink) */}
<div
className="rounded-2xl p-5 relative overflow-hidden"
style={{
background: 'linear-gradient(135deg, var(--at-ink) 0%, color-mix(in oklab, var(--at-ink) 88%, var(--at-brand)) 100%)',
color: 'var(--at-paper)',
}}
>
<div
className="absolute at-serif italic"
style={{ top: -20, right: -20, fontSize: 160, opacity: 0.08, lineHeight: 1 }}
>
</div>
<div className="at-eyebrow" style={{ color: 'color-mix(in oklab, var(--at-paper) 70%, transparent)' }}>
Chuỗi học tập
</div>
<div className="flex items-baseline gap-2.5 mt-2">
<div className="at-serif" style={{ fontSize: 54, fontWeight: 400, letterSpacing: '-0.03em', lineHeight: 0.95 }}>
{streak}
</div>
<div className="at-serif italic" style={{ fontSize: 26, fontWeight: 300 }}>ngày</div>
<span className="material-symbols-outlined" style={{ fontSize: 28, color: '#F5B94A', fontVariationSettings: "'FILL' 1" }}>
local_fire_department
</span>
</div>
<div className="text-[12.5px] opacity-75 mt-2.5">Giữ vững chuỗi học mỗi ngày nhé!</div>
</div>
{/* LEVEL */}
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="at-eyebrow">Cấp đ</div>
<div className="flex items-baseline justify-between mt-2">
<div className="flex items-baseline gap-2.5">
<div className="at-serif" style={{ fontSize: 54, fontWeight: 400, letterSpacing: '-0.03em', lineHeight: 0.95 }}>
{numericLevel}
</div>
<div className="at-serif italic" style={{ fontSize: 20, color: 'var(--at-mute)' }}>Level</div>
</div>
<div
className="w-11 h-11 rounded-xl grid place-items-center"
style={{ background: 'var(--at-paper-2)', color: 'var(--at-brand)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>emoji_events</span>
</div>
</div>
<div className="mt-2.5">
<span className="at-chip at-chip-warm" style={{ fontSize: 10.5 }}>
<span className="at-chip-dot" />
Hạng {levelLabel}
</span>
</div>
</div>
</div>
)}
{/* Row 2 — level ring + week goal + history */}
<div className="grid grid-cols-1 gap-5 mb-5" style={{ gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1.4fr)' }}>
{/* Level progress ring */}
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="flex justify-between items-baseline">
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Tiến đ <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>cấp đ</i>
</div>
<span className="at-serif italic text-[11px]" style={{ color: 'var(--at-mute)' }}>
Lv.{numericLevel} Lv.{numericLevel + 1}
</span>
</div>
<div className="flex justify-center py-5">
<LevelRing value={levelPct} xpInto={xpIntoLevel} xpGoal={100} />
</div>
<div className="text-center text-[12.5px] mb-3" style={{ color: 'var(--at-mute)' }}>
Chỉ còn <b style={{ color: 'var(--at-brand)' }}>{xpLeft} XP</b> nữa đ đt Level {numericLevel + 1}!
</div>
<button
className="w-full py-2.5 rounded-xl text-[13px] font-semibold transition-colors hover:bg-[var(--at-line-2)]"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
>
<span className="material-symbols-outlined inline-block align-middle mr-1" style={{ fontSize: 15 }}>target</span>
Xem nhiệm vụ XP
</button>
</div>
{/* Week goal + history */}
<div className="flex flex-col gap-5">
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="flex justify-between items-start mb-3">
<div>
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Mục tiêu <i style={{ color: 'var(--at-good)', fontStyle: 'italic' }}>tuần</i>
</div>
<div className="text-xs mt-1" style={{ color: 'var(--at-mute)' }}>
Hoàn thành {weekGoalTotal} bài học mỗi tuần
</div>
</div>
<div
className="at-serif"
style={{ fontSize: 36, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1, color: 'var(--at-good)' }}
>
{weeklyCompleted}
<span className="italic" style={{ color: 'var(--at-mute-2)' }}>/{weekGoalTotal}</span>
</div>
</div>
<div className="at-bar" style={{ height: 8 }}>
<span style={{ width: `${(weeklyCompleted / weekGoalTotal) * 100}%`, background: 'var(--at-good)' }} />
</div>
<div className="flex justify-between mt-2.5 text-[11.5px]" style={{ color: 'var(--at-mute)' }}>
<span>Đã hoàn thành</span>
{weeklyCompleted >= weekGoalTotal ? (
<span>
<b style={{ color: 'var(--at-good)' }}>Đt mục tiêu!</b> · +50 XP thưởng
</span>
) : (
<span>
Còn <b style={{ color: 'var(--at-good)' }}>{weekGoalTotal - weeklyCompleted} bài</b>
</span>
)}
</div>
</div>
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="flex justify-between items-baseline mb-4">
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Lịch sử <i style={{ color: 'var(--at-streak)', fontStyle: 'italic' }}>rèn luyện</i>
</div>
<span className="at-serif italic text-[11px]" style={{ color: 'var(--at-mute)' }}>7 ngày qua</span>
</div>
<div className="grid grid-cols-7 gap-2">
{history.map((h, i) => {
const isDone = h.state === 'done'
const isToday = h.state === 'today'
return (
<div key={i} className="flex flex-col items-center gap-2">
<div
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: isToday ? 'var(--at-brand)' : 'var(--at-mute)',
}}
>
{isToday ? 'H.NAY' : h.d}
</div>
<div
className="grid place-items-center"
style={{
width: 44,
height: 44,
borderRadius: 12,
background: isDone ? 'color-mix(in oklab, var(--at-good) 18%, transparent)' : 'transparent',
border: isToday
? '2px dashed var(--at-brand)'
: isDone
? '1px solid color-mix(in oklab, var(--at-good) 30%, transparent)'
: '1px solid var(--at-line)',
color: isDone ? 'var(--at-good)' : isToday ? 'var(--at-brand)' : 'var(--at-mute-2)',
}}
>
{isDone && <span className="material-symbols-outlined" style={{ fontSize: 18 }}>check</span>}
{isToday && <span className="material-symbols-outlined" style={{ fontSize: 14 }}>play_arrow</span>}
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
{/* Row 3 — Xu shop + leaderboard */}
<div className="grid grid-cols-1 gap-5 mb-5" style={{ gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1.4fr)' }}>
{/* Xu shop */}
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="flex justify-between items-baseline mb-3.5">
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Cửa hàng <i style={{ color: '#B88432', fontStyle: 'italic' }}>Xu</i>
</div>
<span className="at-chip" style={{ fontSize: 10.5 }}>
<Coin size={11} /> {xu} xu
</span>
</div>
<div
style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--at-good)', marginBottom: 8 }}
>
Kiếm xu
</div>
{EARN_ITEMS.map((e, i) => (
<div
key={i}
className="flex justify-between items-center py-2.5"
style={{ borderTop: i === 0 ? 'none' : '1px solid var(--at-line)' }}
>
<span className="text-[13px]" style={{ color: 'var(--at-ink-2)' }}>{e.label}</span>
<span
className="inline-flex items-center gap-1 text-xs font-bold"
style={{ color: 'var(--at-good)' }}
>
+{e.amt} <Coin size={11} />
</span>
</div>
))}
<div
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.14em',
textTransform: 'uppercase',
color: '#C8383E',
marginTop: 18,
marginBottom: 8,
}}
>
Tiêu xu
</div>
{SPEND_ITEMS.map((s, i) => (
<div
key={i}
className="flex justify-between items-center py-2.5 gap-2.5"
style={{ borderTop: i === 0 ? 'none' : '1px solid var(--at-line)' }}
>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium" style={{ color: 'var(--at-ink-2)' }}>{s.label}</div>
<div className="text-[11px]" style={{ color: 'var(--at-mute)' }}>{s.desc}</div>
</div>
<button
disabled={xu < s.amt}
className="px-2.5 py-1 rounded-lg text-[11.5px] flex-shrink-0 inline-flex items-center gap-1 transition-opacity disabled:opacity-50"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)', fontWeight: 600 }}
>
{s.amt} <Coin size={11} />
</button>
</div>
))}
</div>
{/* Leaderboard */}
<div
className="rounded-2xl overflow-hidden"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div className="flex justify-between items-center px-5 py-4" style={{ borderBottom: '1px solid var(--at-line)' }}>
<div>
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Bảng xếp hạng <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>tuần</i>
</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--at-mute)' }}>Top học viên tuần này</div>
</div>
<span className="at-chip at-chip-brand" style={{ fontSize: 10.5 }}>
<span className="at-chip-dot" />
Top tuần này
</span>
</div>
<div
className="grid px-5 py-2.5 text-[10px]"
style={{
gridTemplateColumns: '60px 1fr auto',
gap: 12,
background: 'var(--at-paper-2)',
fontWeight: 700,
color: 'var(--at-mute)',
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}
>
<span>Hạng</span>
<span>Người học</span>
<span>XP tuần</span>
</div>
{board.length === 0 ? (
<div className="px-5 py-8 text-center text-sm" style={{ color: 'var(--at-mute)' }}>
Chưa ai trên bảng xếp hạng tuần này.
</div>
) : (
board.map((p) => {
const rankColors: Record<number, string> = { 1: '#F5B94A', 2: '#BFC5CC', 3: '#C8844A' }
const rc = rankColors[p.rank]
return (
<div
key={p.rank}
className="grid items-center px-5 py-3.5"
style={{
gridTemplateColumns: '60px 1fr auto',
gap: 12,
borderTop: '1px solid var(--at-line)',
background: p.you ? 'color-mix(in oklab, var(--at-brand) 6%, transparent)' : 'transparent',
}}
>
<div
className="w-7 h-7 rounded-full grid place-items-center at-serif"
style={{
background: rc ? `color-mix(in oklab, ${rc} 25%, var(--at-paper-2))` : 'var(--at-paper-2)',
border: rc ? `1px solid ${rc}` : '1px solid var(--at-line)',
color: rc ? 'var(--at-ink)' : 'var(--at-mute)',
fontSize: 14,
fontWeight: 500,
}}
>
{p.rank}
</div>
<div className="flex items-center gap-2.5 min-w-0">
<div
className="w-8 h-8 rounded-full grid place-items-center text-[13px] font-bold flex-shrink-0"
style={{ background: p.you ? 'var(--at-brand)' : 'var(--at-ink-2)', color: 'var(--at-paper)' }}
>
{p.avatar}
</div>
<div className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
<span
className="text-[13.5px]"
style={{ fontWeight: p.you ? 700 : 500, color: p.you ? 'var(--at-brand)' : 'var(--at-ink)' }}
>
{p.name}
</span>
</div>
</div>
<div className="at-serif" style={{ fontSize: 17, fontWeight: 400, letterSpacing: '-0.01em' }}>
{p.xp}
<span className="italic ml-1" style={{ fontSize: 11, color: 'var(--at-mute)', fontWeight: 400 }}>XP</span>
</div>
</div>
)
})
)}
</div>
</div>
{/* Row 4 — Badges */}
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="flex justify-between items-baseline mb-4">
<div>
<div className="at-eyebrow mb-1">Huy hiệu</div>
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Thành tựu <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>đã mở</i>
</div>
</div>
<span className="text-xs" style={{ color: 'var(--at-mute)' }}>
<b style={{ color: 'var(--at-ink)' }}>{BADGES.filter((b) => b.earned).length}</b> / {BADGES.length} mở khoá
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3.5">
{BADGES.map((b) => (
<div
key={b.id}
className="p-4 rounded-2xl relative"
style={{
background: b.earned ? 'var(--at-surface)' : 'var(--at-paper-2)',
border: '1px solid var(--at-line)',
opacity: b.earned ? 1 : 0.72,
}}
>
<div
className="w-12 h-12 rounded-xl grid place-items-center mb-3"
style={{
background: b.earned ? `color-mix(in oklab, ${b.color} 16%, transparent)` : 'var(--at-line-2)',
color: b.earned ? b.color : 'var(--at-mute-2)',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>{b.icon}</span>
</div>
<div
className="at-serif mb-1"
style={{ fontSize: 16, fontWeight: 500, letterSpacing: '-0.01em', color: 'var(--at-ink)' }}
>
{b.name}
</div>
<div className="text-[11.5px] leading-[1.4] mb-2" style={{ color: 'var(--at-mute)' }}>{b.desc}</div>
{b.earned ? (
<span className="at-chip at-chip-good" style={{ fontSize: 10 }}>
<span className="at-chip-dot" />
Đã mở
</span>
) : (
<div>
<div className="at-bar" style={{ height: 4, marginBottom: 4 }}>
<span style={{ width: `${b.progress ?? 0}%`, background: b.color }} />
</div>
<span className="text-[10.5px] font-semibold" style={{ color: 'var(--at-mute)' }}>
{b.progress ?? 0}% tiến đ
</span>
</div>
)}
</div>
))}
</div>
</div>
</div>
)
}
function LevelRing({ value, xpInto, xpGoal }: { value: number; xpInto: number; xpGoal: number }) {
const r = 80
const c = 2 * Math.PI * r
const offset = c - (value / 100) * c
return (
<div className="relative grid place-items-center" style={{ width: 180, height: 180 }}>
<svg width="180" height="180">
<circle cx="90" cy="90" r={r} fill="none" stroke="var(--at-line-2)" strokeWidth="10" />
<circle
cx="90"
cy="90"
r={r}
fill="none"
stroke="var(--at-brand)"
strokeWidth="10"
strokeDasharray={c}
strokeDashoffset={offset}
strokeLinecap="round"
transform="rotate(-90 90 90)"
style={{ transition: 'stroke-dashoffset 0.6s cubic-bezier(0.2, 0.7, 0.2, 1)' }}
/>
</svg>
<div className="absolute text-center">
<div className="at-serif" style={{ fontSize: 40, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1, color: 'var(--at-ink)' }}>
{value}%
</div>
<div style={{ fontSize: 10.5, color: 'var(--at-mute)', marginTop: 4, fontWeight: 600, letterSpacing: '0.1em' }}>
{xpInto} / {xpGoal} XP
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,154 @@
import { supabase } from '@/lib/supabase'
import { EASE, computeNextReview, statusFor, type EaseKey } from '../lib/srs-intervals'
export interface FlashcardList {
id: number
title: string
description: string | null
total_words: number
is_public: boolean
created_by: string | null
created_at: string
// aggregated from user progress
count_new?: number
count_learning?: number
count_known?: number
progress_pct?: number
}
export interface FlashcardTerm {
id: number
list_id: number
word: string
part_of_speech: string | null
phonetic: string | null
definition: string | null
example: string | null
image_url: string | null
audio_tts_text: string | null
audio_lang: string | null
display_order: number
}
export interface UserProgress {
id: number
user_id: string
term_id: number
list_id: number
status: 'new' | 'learning' | 'known' | 'ignored'
ease_factor: number
review_count: number
last_reviewed_at: string | null
next_review_at: string | null
}
/** Fetch all public flashcard lists with term counts */
export async function fetchFlashcardLists(): Promise<FlashcardList[]> {
const { data, error } = await supabase
.from('flashcard_list')
.select('id, title, description, total_words, is_public, created_by, created_at')
.order('created_at', { ascending: false })
if (error) throw error
return data ?? []
}
/** Fetch all terms for a flashcard list */
export async function fetchFlashcardTerms(listId: number): Promise<FlashcardTerm[]> {
const { data, error } = await supabase
.from('flashcard_term')
.select('id, list_id, word, part_of_speech, phonetic, definition, example, image_url, audio_tts_text, audio_lang, display_order')
.eq('list_id', listId)
.order('display_order', { ascending: true })
if (error) throw error
return data ?? []
}
/** Fetch user progress for all terms in a list */
export async function fetchUserProgress(userId: string, listId: number): Promise<UserProgress[]> {
const { data, error } = await supabase
.from('user_flashcard_progress')
.select('id, user_id, term_id, list_id, status, ease_factor, review_count, last_reviewed_at, next_review_at')
.eq('user_id', userId)
.eq('list_id', listId)
if (error) throw error
return data ?? []
}
/** Upsert user progress for a term. Increments review_count, writes next_review_at via interval ladder. */
export async function upsertTermProgress(
userId: string,
termId: number,
listId: number,
easeKey: EaseKey,
currentReviewCount: number,
): Promise<void> {
const now = new Date().toISOString()
const nextReview = computeNextReview(easeKey, currentReviewCount)
const { error } = await supabase
.from('user_flashcard_progress')
.upsert(
{
user_id: userId,
term_id: termId,
list_id: listId,
status: statusFor(easeKey),
ease_factor: EASE[easeKey],
review_count: currentReviewCount + 1,
last_reviewed_at: now,
next_review_at: nextReview,
},
{ onConflict: 'user_id,term_id,list_id' },
)
if (error) console.error('upsertTermProgress failed:', error.message)
}
export interface LearnSession {
id: number
user_id: string
list_id: number
started_at: string
}
export async function startSession(userId: string, listId: number): Promise<LearnSession> {
const { data, error } = await supabase
.from('user_flashcard_session')
.insert({ user_id: userId, list_id: listId })
.select('id, user_id, list_id, started_at')
.single()
if (error) throw error
return data as LearnSession
}
export async function endSession(
sessionId: number,
termsReviewed: number,
termsNew: number,
): Promise<void> {
const { error } = await supabase
.from('user_flashcard_session')
.update({
ended_at: new Date().toISOString(),
terms_reviewed: termsReviewed,
terms_new: termsNew,
})
.eq('id', sessionId)
if (error) console.error('endSession failed:', error.message)
}
export async function logReview(
sessionId: number,
userId: string,
termId: number,
actionValue: number,
): Promise<void> {
const { error } = await supabase
.from('user_flashcard_review_log')
.insert({
session_id: sessionId,
user_id: userId,
term_id: termId,
action_value: actionValue,
})
if (error) console.error('logReview failed:', error.message)
}

View File

@@ -0,0 +1,59 @@
import { cn } from '@/lib/utils'
interface FlashCardProps {
word: string
phonetic: string
meaningVi: string
example: string
topicBadge: string
isFlipped: boolean
onFlip: () => void
}
/** 3D flip flashcard. Front shows word/phonetic; back shows Vietnamese meaning + example. */
export function FlashCard({ word, phonetic, meaningVi, example, topicBadge, isFlipped, onFlip }: FlashCardProps) {
const highlightedExample = example.replace(
new RegExp(`\\b${word}\\b`, 'gi'),
(match) => `<strong>${match}</strong>`,
)
return (
<div
className="flashcard-scene w-full cursor-pointer select-none"
style={{ height: 280 }}
onClick={onFlip}
role="button"
aria-label={isFlipped ? 'Nhấn để xem từ' : 'Nhấn để xem nghĩa'}
>
<div className={cn('flashcard-inner w-full h-full', isFlipped && 'is-flipped')}>
{/* Front */}
<div className="flashcard-face bg-white border border-slate-200 shadow-lg flex flex-col items-center justify-center p-8">
<div className="text-4xl font-extrabold text-slate-800 mb-2">{word}</div>
<div className="text-slate-400 text-lg mb-4">{phonetic}</div>
<span className="bg-blue-50 text-blue-600 text-xs font-bold px-3 py-1 rounded-full">
{topicBadge}
</span>
<p className="mt-6 text-xs text-slate-400 flex items-center gap-1">
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>touch_app</span>
Nhấn đ xem nghĩa
</p>
</div>
{/* Back */}
<div
className="flashcard-face flashcard-back flex flex-col items-center justify-center p-8"
style={{ background: 'linear-gradient(135deg, #eff6ff, #dbeafe)' }}
>
<div className="text-3xl font-extrabold text-blue-600 mb-1">{meaningVi}</div>
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wide mb-4">
Nghĩa tiếng Việt
</div>
<div
className="bg-white rounded-xl p-3 border border-blue-100 text-sm text-slate-500 italic text-center"
dangerouslySetInnerHTML={{ __html: `"${highlightedExample}"` }}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,604 @@
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth-store'
import {
fetchFlashcardTerms,
fetchUserProgress,
upsertTermProgress,
startSession,
endSession,
logReview,
fetchFlashcardLists,
} from '../api/flashcard-api'
import type { FlashcardTerm, UserProgress } from '../api/flashcard-api'
import { EASE, type EaseKey } from '../lib/srs-intervals'
interface Props {
listId: number
}
type SessionStats = { known: number; learning: number; ignored: number }
function speak(word: string) {
try {
const u = new SpeechSynthesisUtterance(word)
u.lang = 'en-US'
u.rate = 0.9
speechSynthesis.cancel()
speechSynthesis.speak(u)
} catch { /* noop */ }
}
export function FlashCardLearnPage({ listId }: Props) {
const navigate = useNavigate()
const user = useAuthStore(s => s.user)
const queryClient = useQueryClient()
const [isFlipped, setIsFlipped] = useState(false)
const [currentIdx, setCurrentIdx] = useState(0)
const [sessionStats, setSessionStats] = useState<SessionStats>({ known: 0, learning: 0, ignored: 0 })
const [isDone, setIsDone] = useState(false)
const [fx, setFx] = useState<'known' | 'review' | null>(null)
// Bookmarks — per-list, persisted in localStorage
const bookmarkKey = `flashcard-bookmarks-${listId}`
const [bookmarks, setBookmarks] = useState<Set<number>>(() => {
try {
const raw = localStorage.getItem(bookmarkKey)
return raw ? new Set<number>(JSON.parse(raw)) : new Set()
} catch { return new Set() }
})
const toggleBookmark = useCallback((termId: number) => {
setBookmarks(prev => {
const next = new Set(prev)
if (next.has(termId)) next.delete(termId)
else next.add(termId)
try { localStorage.setItem(bookmarkKey, JSON.stringify([...next])) } catch { /* noop */ }
return next
})
}, [bookmarkKey])
// Refs for unmount cleanup so effects see fresh values
const sessionIdRef = useRef<number | null>(null)
const statsRef = useRef<SessionStats>(sessionStats)
const isDoneRef = useRef(false)
const newTermIdsAtStartRef = useRef<Set<number>>(new Set())
const answeredNewIdsRef = useRef<Set<number>>(new Set())
useEffect(() => { statsRef.current = sessionStats }, [sessionStats])
useEffect(() => { isDoneRef.current = isDone }, [isDone])
const { data: terms = [], isLoading: loadingTerms } = useQuery({
queryKey: ['flashcard-terms', listId],
queryFn: () => fetchFlashcardTerms(listId),
})
const { data: lists = [] } = useQuery({
queryKey: ['flashcard-lists'],
queryFn: fetchFlashcardLists,
staleTime: 5 * 60 * 1000,
})
const currentList = lists.find(l => l.id === listId)
const { data: progress = [] } = useQuery({
queryKey: ['flashcard-progress', user?.id, listId],
queryFn: () => fetchUserProgress(user!.id, listId),
enabled: !!user,
})
const progressMap = useMemo(() => {
const m: Record<number, UserProgress> = {}
progress.forEach(p => { m[p.term_id] = p })
return m
}, [progress])
// Session term ordering: prioritise due-for-review, then new, then known
const sessionTerms: FlashcardTerm[] = useMemo(() => {
if (!terms.length) return []
const now = Date.now()
const due: FlashcardTerm[] = []
const fresh: FlashcardTerm[] = []
const known: FlashcardTerm[] = []
for (const t of terms) {
const p = progressMap[t.id]
if (p?.status === 'ignored') continue
if (!p) { fresh.push(t); continue }
if (!p.next_review_at) { fresh.push(t); continue }
if (new Date(p.next_review_at).getTime() <= now) { due.push(t); continue }
if (p.status === 'known') known.push(t)
else fresh.push(t)
}
return [...due, ...fresh, ...known]
}, [terms, progressMap])
// Snapshot "new" term IDs at session start (runs once when data is loaded)
useEffect(() => {
if (newTermIdsAtStartRef.current.size === 0 && terms.length > 0) {
const newIds = new Set<number>()
for (const t of terms) {
const s = progressMap[t.id]?.status ?? 'new'
if (s === 'new') newIds.add(t.id)
}
newTermIdsAtStartRef.current = newIds
}
}, [terms, progressMap])
// Start session on mount (guarded against StrictMode double-invoke)
useEffect(() => {
if (!user || sessionIdRef.current !== null) return
let cancelled = false
startSession(user.id, listId)
.then(s => { if (!cancelled) sessionIdRef.current = s.id })
.catch(err => console.error('startSession failed:', err))
return () => { cancelled = true }
}, [user, listId])
// End session on unmount (if not already ended via done-screen effect)
useEffect(() => {
return () => {
const sid = sessionIdRef.current
if (sid === null || isDoneRef.current) return
const s = statsRef.current
const reviewed = s.known + s.learning + s.ignored
endSession(sid, reviewed, answeredNewIdsRef.current.size)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// End session when reaching done screen
useEffect(() => {
if (!isDone) return
const sid = sessionIdRef.current
if (sid === null) return
const s = statsRef.current
const reviewed = s.known + s.learning + s.ignored
endSession(sid, reviewed, answeredNewIdsRef.current.size)
}, [isDone])
const { mutate: saveAnswer } = useMutation({
mutationFn: async ({ termId, easeKey, reviewCount }: {
termId: number
easeKey: EaseKey
reviewCount: number
}) => {
if (!user) return
const sid = sessionIdRef.current
await Promise.all([
upsertTermProgress(user.id, termId, listId, easeKey, reviewCount),
sid !== null ? logReview(sid, user.id, termId, EASE[easeKey]) : Promise.resolve(),
])
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['flashcard-progress', user?.id, listId] })
},
})
const advance = useCallback(() => {
if (currentIdx + 1 >= sessionTerms.length) {
setIsDone(true)
} else {
setCurrentIdx(i => i + 1)
setIsFlipped(false)
}
setFx(null)
}, [currentIdx, sessionTerms.length])
const handleAnswer = useCallback((key: EaseKey) => {
const term = sessionTerms[currentIdx]
if (!term || !user) return
const currentProgress = progressMap[term.id]
const reviewCount = currentProgress?.review_count ?? 0
saveAnswer({ termId: term.id, easeKey: key, reviewCount })
if (newTermIdsAtStartRef.current.has(term.id)) {
answeredNewIdsRef.current.add(term.id)
}
setSessionStats(prev => ({
known: prev.known + (key === 'known' ? 1 : 0),
learning: prev.learning + (key === 'easy' || key === 'hard' ? 1 : 0),
ignored: prev.ignored + (key === 'ignored' ? 1 : 0),
}))
// Visual feedback: known swipes right, hard/ignored swipes left
if (key === 'known' || key === 'easy') {
setFx('known')
} else {
setFx('review')
}
setTimeout(advance, 450)
}, [currentIdx, sessionTerms, user, saveAnswer, progressMap, advance])
// Keyboard shortcuts
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (isDone || !sessionTerms[currentIdx]) return
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault()
setIsFlipped(v => !v)
return
}
if (!isFlipped) return
if (e.key.toLowerCase() === 'j') handleAnswer('known')
else if (e.key.toLowerCase() === 'k') handleAnswer('hard')
else if (e.key.toLowerCase() === 'i') handleAnswer('ignored')
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [isDone, isFlipped, currentIdx, sessionTerms, handleAnswer])
const total = sessionTerms.length
const progressPct = total > 0 ? Math.round((currentIdx / total) * 100) : 0
const current = sessionTerms[currentIdx]
if (loadingTerms) {
return (
<div className="atelier flex items-center justify-center min-h-screen">
<div className="w-8 h-8 border-2 border-[var(--at-line)] border-t-[var(--at-accent)] rounded-full animate-spin" />
</div>
)
}
if (sessionTerms.length === 0) {
return (
<div className="atelier flex flex-col items-center justify-center min-h-screen gap-4 px-4">
<div className="at-serif text-5xl italic text-[var(--at-mute-2)]">All clear.</div>
<p className="text-[var(--at-mute)] text-center max-w-sm">
Không thẻ nào cần học ngay bây giờ. Quay lại sau khi đến lịch ôn tập.
</p>
<button
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
className="mt-4 px-6 py-2.5 bg-[var(--at-ink)] text-[var(--at-paper)] rounded-xl text-sm font-semibold hover:opacity-90 transition"
>
Quay lại danh sách
</button>
</div>
)
}
if (isDone) {
return (
<div className="atelier flex flex-col items-center justify-center min-h-screen gap-8 px-4">
<div className="text-center">
<div className="at-serif italic text-[var(--at-accent)] text-6xl mb-4">Bravo.</div>
<h2 className="at-serif text-3xl tracking-tight text-[var(--at-ink)] mb-2">Hoàn thành phiên học</h2>
<p className="text-[var(--at-mute)]">Bạn đã ôn xong {total} thẻ trong phiên này</p>
</div>
<div className="flex gap-3">
<div className="px-5 py-3 rounded-2xl border border-[var(--at-line)] bg-white text-center min-w-[88px]">
<div className="at-serif text-3xl text-[var(--at-good)]">{sessionStats.known}</div>
<div className="text-[10px] uppercase tracking-widest text-[var(--at-mute)] mt-1">Đã biết</div>
</div>
<div className="px-5 py-3 rounded-2xl border border-[var(--at-line)] bg-white text-center min-w-[88px]">
<div className="at-serif text-3xl text-[var(--at-accent)]">{sessionStats.learning}</div>
<div className="text-[10px] uppercase tracking-widest text-[var(--at-mute)] mt-1">Đang học</div>
</div>
<div className="px-5 py-3 rounded-2xl border border-[var(--at-line)] bg-white text-center min-w-[88px]">
<div className="at-serif text-3xl text-[var(--at-mute-2)]">{sessionStats.ignored}</div>
<div className="text-[10px] uppercase tracking-widest text-[var(--at-mute)] mt-1">Bỏ qua</div>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => {
setCurrentIdx(0)
setIsFlipped(false)
setIsDone(false)
setSessionStats({ known: 0, learning: 0, ignored: 0 })
sessionIdRef.current = null
answeredNewIdsRef.current = new Set()
newTermIdsAtStartRef.current = new Set()
if (user) startSession(user.id, listId).then(s => { sessionIdRef.current = s.id })
}}
className="px-5 py-2.5 bg-[var(--at-ink)] text-[var(--at-paper)] rounded-xl text-sm font-semibold hover:opacity-90 transition"
>
Học lại
</button>
<button
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
className="px-5 py-2.5 border border-[var(--at-line)] text-[var(--at-ink-2)] rounded-xl text-sm font-semibold bg-white hover:border-[var(--at-ink)] transition"
>
Xem danh sách
</button>
</div>
</div>
)
}
// Jump to a specific card in the deck (no progress write — just navigate)
const jumpTo = (idx: number) => {
setCurrentIdx(idx)
setIsFlipped(false)
}
return (
<div
className="atelier fixed top-16 right-0 left-0 lg:left-60 bottom-20 lg:bottom-0 flex flex-col px-4 lg:px-6 py-3 overflow-hidden"
style={{ background: 'var(--at-paper)' }}
>
{/* Header row: breadcrumb + serif title on left, actions on right */}
<div className="flex items-end justify-between gap-4 mb-4 flex-shrink-0 min-w-0">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1 text-[13px]" style={{ color: 'var(--at-mute)' }}>
<button
onClick={() => navigate({ to: '/flash-card' })}
className="hover:text-[var(--at-ink)] transition-colors"
>
Chủ đ
</button>
<span>/</span>
<button
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
className="hover:text-[var(--at-ink)] transition-colors truncate"
style={{ color: 'var(--at-ink-2)' }}
>
{currentList?.title ?? 'Bộ thẻ'}
</button>
</div>
<h1
className="at-serif tracking-tight"
style={{ fontSize: 40, fontWeight: 400, letterSpacing: '-0.025em', lineHeight: 1.05, color: 'var(--at-ink)' }}
>
Thẻ <i style={{ fontStyle: 'italic', color: 'var(--at-brand)' }}>{currentIdx + 1}</i>
<span className="at-serif italic" style={{ color: 'var(--at-mute-2)' }}> / {total}</span>
</h1>
</div>
<div className="flex gap-2 flex-shrink-0">
<button
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(listId) } })}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] font-semibold transition-colors hover:bg-[var(--at-line-2)]"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 15 }}>arrow_back</span>
Danh sách
</button>
<button
onClick={() => current && toggleBookmark(current.id)}
disabled={!current}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] font-semibold transition-colors hover:bg-[var(--at-line-2)]"
style={{
background: current && bookmarks.has(current.id) ? 'var(--at-warm-soft)' : 'var(--at-surface)',
border: '1px solid ' + (current && bookmarks.has(current.id) ? 'var(--at-warm)' : 'var(--at-line)'),
color: current && bookmarks.has(current.id) ? 'var(--at-warm-ink)' : 'var(--at-ink-2)',
}}
>
<span
className="material-symbols-outlined"
style={{
fontSize: 15,
fontVariationSettings: current && bookmarks.has(current.id) ? "'FILL' 1" : "'FILL' 0",
}}
>
bookmark
</span>
Đánh dấu
</button>
</div>
</div>
{/* Body: card column + sidebar */}
<div
className="flex-1 min-h-0 lg:grid flex flex-col gap-5"
style={{ gridTemplateColumns: 'minmax(0, 1fr) 260px' }}
>
{/* Main: card + actions + progress */}
<div className="flex flex-col items-center justify-center min-h-0">
{/* Card */}
{current && (
<div className="at-card-outer" style={{ maxWidth: 420, flexShrink: 0 }}>
<div
className={cn('at-card', isFlipped && 'is-flipped', fx === 'known' && 'fx-known', fx === 'review' && 'fx-review')}
key={current.id}
onClick={() => setIsFlipped(v => !v)}
role="button"
tabIndex={0}
aria-label={isFlipped ? 'Lật để xem từ' : 'Lật để xem nghĩa'}
>
{/* FRONT */}
<div className="at-card-face" style={{ padding: '20px 24px' }}>
<div className="flex items-center justify-between">
<span className="at-chip">
<span className="at-chip-dot" />
{current.part_of_speech?.toUpperCase() ?? 'TỪ VỰNG'}
</span>
<button
onClick={(e) => { e.stopPropagation(); speak(current.audio_tts_text ?? current.word) }}
className="w-9 h-9 rounded-lg grid place-items-center text-[var(--at-mute)] hover:bg-[var(--at-accent-soft)] hover:text-[var(--at-accent)] transition"
aria-label="Phát âm"
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>volume_up</span>
</button>
</div>
<div className="flex-1 flex flex-col justify-center">
<div className="at-word" style={{ fontSize: 'clamp(40px, 5vw, 60px)' }}>{current.word}</div>
{(current.phonetic || current.part_of_speech) && (
<div className="at-mono text-sm text-[var(--at-mute)] mt-3">
{current.phonetic}
{current.part_of_speech && (
<span className="at-serif italic text-[var(--at-mute-2)]"> · {current.part_of_speech}</span>
)}
</div>
)}
</div>
<div className="flex items-center justify-center gap-2 text-[11.5px] text-[var(--at-mute)]">
<span className="at-kbd">Space</span>
<span>đ lật thẻ</span>
</div>
</div>
{/* BACK */}
<div className="at-card-face at-card-back" style={{ padding: '20px 24px' }}>
<div className="flex items-center justify-between">
<span className="at-chip at-chip-mute">
<span className="at-chip-dot" />
NGHĨA
</span>
<button
onClick={(e) => { e.stopPropagation(); speak(current.audio_tts_text ?? current.word) }}
className="w-9 h-9 rounded-lg grid place-items-center text-[var(--at-mute)] hover:bg-[var(--at-accent-soft)] hover:text-[var(--at-accent)] transition"
aria-label="Phát âm"
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>volume_up</span>
</button>
</div>
<div className="flex-1 flex flex-col justify-center gap-4">
<div className="at-meaning" style={{ fontSize: 22 }}>{current.definition ?? '—'}</div>
{current.example && (
<div className="at-example">
<div className="at-serif italic text-[14px] leading-[1.45] text-[var(--at-ink-2)]">
"{current.example}"
</div>
</div>
)}
</div>
<div className="flex items-center justify-center gap-2 text-[11.5px] text-[var(--at-mute)]">
<span className="at-kbd"></span>
<span>lật lại</span>
</div>
</div>
</div>
</div>
)}
{/* Actions */}
<div className="mt-4 w-full" style={{ maxWidth: 420 }}>
<div className={cn('flex items-stretch gap-2.5 w-full transition-opacity duration-300', !isFlipped && 'opacity-40 pointer-events-none')}>
<button onClick={() => handleAnswer('ignored')} disabled={!isFlipped} className="at-action" style={{ padding: '11px 14px', fontSize: 13 }}>
Bỏ qua <span className="at-kbd">I</span>
</button>
<button onClick={() => handleAnswer('hard')} disabled={!isFlipped} className="at-action at-action-review" style={{ padding: '11px 14px', fontSize: 13 }}>
Cần ôn <span className="at-kbd">K</span>
</button>
<button onClick={() => handleAnswer('known')} disabled={!isFlipped} className="at-action at-action-known" style={{ padding: '11px 14px', fontSize: 13 }}>
Đã thuộc
<span className="at-kbd" style={{ background: 'rgba(255,255,255,0.16)', color: 'rgba(255,255,255,0.9)', border: 'none' }}>J</span>
</button>
</div>
</div>
{/* Progress */}
<div className="mt-3 w-full" style={{ maxWidth: 420 }}>
<div className="flex items-baseline justify-between mb-1.5 text-[12px] text-[var(--at-mute)]">
<span>
<b className="text-[var(--at-ink)] tabular-nums">{currentIdx + 1}</b> / {total} ·{' '}
{sessionStats.known} biết · {sessionStats.learning} học · {sessionStats.ignored} bỏ
</span>
<span className="at-pct" style={{ fontSize: 18 }}>{progressPct}%</span>
</div>
<div className="at-progress-bar">
<span style={{ width: `${progressPct}%` }} />
</div>
</div>
</div>
{/* Right sidebar */}
<aside className="hidden lg:flex flex-col gap-3 min-h-0">
{/* Today stats */}
<div
className="rounded-2xl p-4 flex-shrink-0"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div className="at-eyebrow mb-2" style={{ fontSize: 11 }}>Hôm nay</div>
<div className="grid grid-cols-2 gap-3 mt-1">
<div>
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}>
Đã học
</div>
<div
className="at-serif"
style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-ink)' }}
>
{sessionStats.known + sessionStats.learning + sessionStats.ignored}
</div>
</div>
<div>
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}>
Đúng
</div>
<div
className="at-serif"
style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-good)' }}
>
{sessionStats.known}
</div>
</div>
</div>
</div>
{/* Cards in deck — compact rows (word only) */}
<div
className="rounded-2xl p-3 flex flex-col"
style={{
background: 'var(--at-surface)',
border: '1px solid var(--at-line)',
maxHeight: 'calc((100vh - 4rem) / 2)',
}}
>
<div className="at-eyebrow mb-2 px-1" style={{ fontSize: 11 }}>Trong bộ này</div>
<div className="flex-1 min-h-0 overflow-y-auto -mx-1 px-1">
{sessionTerms.map((t, i) => {
const p = progressMap[t.id]
const isActive = i === currentIdx
const isKnown = p?.status === 'known'
const isBookmarked = bookmarks.has(t.id)
return (
<button
key={t.id}
onClick={() => jumpTo(i)}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left transition-colors"
style={{
background: isActive ? 'var(--at-brand-soft)' : 'transparent',
borderTop: i === 0 || isActive ? 'none' : '1px solid var(--at-line)',
}}
>
<span
className="at-serif italic flex-shrink-0 text-center"
style={{ fontSize: 13, color: 'var(--at-mute)', width: 20 }}
>
{i + 1}
</span>
<div className="flex-1 min-w-0">
<div
className="text-[13px] font-bold truncate"
style={{ color: isActive ? 'var(--at-brand-ink)' : 'var(--at-ink)' }}
>
{t.word}
</div>
<div
className="text-[11.5px] truncate mt-0.5"
style={{ color: 'var(--at-mute)' }}
>
{t.definition ?? '—'}
</div>
</div>
{isBookmarked && (
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: 13, color: 'var(--at-warm)', fontVariationSettings: "'FILL' 1" }}
>
bookmark
</span>
)}
{isKnown && (
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: 14, color: 'var(--at-good)' }}>
check
</span>
)}
</button>
)
})}
</div>
</div>
</aside>
</div>
</div>
)
}

View File

@@ -0,0 +1,178 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { fetchFlashcardLists } from '../api/flashcard-api'
import { useAuthStore } from '@/store/auth-store'
import { fetchUserProgress } from '../api/flashcard-api'
import type { FlashcardList } from '../api/flashcard-api'
function ListCard({ list, userId }: { list: FlashcardList; userId: string | null }) {
const navigate = useNavigate()
const { data: progress = [] } = useQuery({
queryKey: ['flashcard-progress', userId, list.id],
queryFn: () => fetchUserProgress(userId!, list.id),
enabled: !!userId,
})
const countLearning = progress.filter(p => p.status === 'learning').length
const countKnown = progress.filter(p => p.status === 'known').length
const progressPct = list.total_words > 0
? Math.round(((countLearning + countKnown) / list.total_words) * 100)
: 0
return (
<div
className="rounded-2xl p-6 flex flex-col transition-all hover:-translate-y-1"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-3">
<div
className="w-11 h-11 rounded-xl grid place-items-center flex-shrink-0 at-serif italic"
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)', fontSize: 18, fontWeight: 500 }}
>
{list.title.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<h3
className="at-serif text-[17px] leading-[1.2] tracking-tight line-clamp-2"
style={{ color: 'var(--at-ink)', fontWeight: 500 }}
>
{list.title}
</h3>
<div className="flex items-center gap-1.5 mt-1">
<span className="material-symbols-outlined" style={{ fontSize: 13, color: 'var(--at-mute)' }}>book</span>
<span className="text-xs font-medium" style={{ color: 'var(--at-mute)' }}>
{list.total_words} từ
</span>
</div>
</div>
</div>
<span className={`at-chip ${list.is_public ? 'at-chip-brand' : ''}`}>
<span className="at-chip-dot" />
{list.is_public ? 'Công khai' : 'Riêng tư'}
</span>
</div>
{list.description && (
<p className="text-xs leading-[1.5] mb-4 line-clamp-2" style={{ color: 'var(--at-mute)' }}>
{list.description}
</p>
)}
<div className="mt-2 mb-4">
<div className="flex justify-between items-baseline mb-2">
<span className="at-eyebrow" style={{ fontSize: 10 }}>Tiến đ</span>
<span
className="at-serif italic"
style={{ fontSize: 18, color: 'var(--at-brand)', letterSpacing: '-0.02em', lineHeight: 1 }}
>
{progressPct}%
</span>
</div>
<div className="at-bar">
<span style={{ width: `${progressPct}%` }} />
</div>
</div>
<div className="grid grid-cols-3 gap-2 mb-5">
<Stat num={list.total_words - countLearning - countKnown} label="Mới" />
<Stat num={countLearning} label="Học" color="var(--at-brand)" />
<Stat num={countKnown} label="Biết" color="var(--at-good)" />
</div>
<div className="grid grid-cols-2 gap-2 mt-auto">
<button
onClick={() => navigate({ to: '/flash-card/$listId', params: { listId: String(list.id) } })}
className="py-2.5 rounded-xl text-[13px] font-semibold transition-colors"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
>
Xem thẻ
</button>
<button
onClick={() => navigate({ to: '/flash-card/$listId/learn', params: { listId: String(list.id) } })}
className="py-2.5 rounded-xl text-[13px] font-semibold transition-opacity hover:opacity-90"
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
>
Học ngay
</button>
</div>
</div>
)
}
function Stat({ num, label, color }: { num: number; label: string; color?: string }) {
return (
<div className="text-center py-1.5 rounded-lg" style={{ background: 'var(--at-paper-2)' }}>
<div
className="at-serif"
style={{ fontSize: 20, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1, color: color ?? 'var(--at-ink)' }}
>
{num}
</div>
<div className="text-[10px] font-semibold mt-1 tracking-wider uppercase" style={{ color: 'var(--at-mute)' }}>
{label}
</div>
</div>
)
}
export function FlashCardListPage() {
const user = useAuthStore(s => s.user)
const { data: lists = [], isLoading, isError } = useQuery({
queryKey: ['flashcard-lists'],
queryFn: fetchFlashcardLists,
})
return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div>
<div className="at-eyebrow mb-3">Từ vựng TOEIC</div>
<h1 className="at-title text-4xl lg:text-[44px]">
Bộ thẻ <i>ghi nhớ</i>
</h1>
<p className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
Chọn bộ thẻ đ bắt đu {lists.length} bộ sưu tầm
</p>
</div>
</div>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="rounded-2xl p-6 h-64 animate-pulse"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
/>
))}
</div>
) : isError ? (
<div
className="rounded-2xl p-10 text-center"
style={{ background: 'var(--at-bad-soft)', border: '1px solid rgba(193,68,62,0.2)' }}
>
<p className="text-sm" style={{ color: 'var(--at-bad)' }}>
Không thể tải danh sách bộ thẻ. Vui lòng thử lại.
</p>
</div>
) : lists.length === 0 ? (
<div
className="rounded-2xl p-16 text-center"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<span className="material-symbols-outlined mb-3 block" style={{ fontSize: 48, color: 'var(--at-mute-2)' }}>library_books</span>
<p className="at-serif text-lg" style={{ color: 'var(--at-ink)' }}>Chưa bộ thẻ nào.</p>
<p className="text-sm mt-1" style={{ color: 'var(--at-mute)' }}>Bộ thẻ từ vựng TOEIC sẽ đưc thêm sớm!</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{lists.map(list => (
<ListCard key={list.id} list={list} userId={user?.id ?? null} />
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,249 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth-store'
import { fetchFlashcardTerms, fetchUserProgress } from '../api/flashcard-api'
import type { FlashcardTerm, UserProgress } from '../api/flashcard-api'
import { resolveMediaUrl } from '../lib/media-url'
type FilterStatus = 'all' | 'new' | 'learning' | 'known' | 'ignored'
const STATUS_LABEL: Record<string, string> = {
new: 'Mới',
learning: 'Đang học',
known: 'Đã biết',
ignored: 'Bỏ qua',
}
const STATUS_CLASS: Record<string, string> = {
new: 'at-chip',
learning: 'at-chip at-chip-brand',
known: 'at-chip at-chip-good',
ignored: 'at-chip at-chip-warm',
}
interface Props {
listId: number
}
export function FlashCardTermsPage({ listId }: Props) {
const navigate = useNavigate()
const user = useAuthStore(s => s.user)
const [filter, setFilter] = useState<FilterStatus>('all')
const [search, setSearch] = useState('')
const { data: terms = [], isLoading: loadingTerms } = useQuery({
queryKey: ['flashcard-terms', listId],
queryFn: () => fetchFlashcardTerms(listId),
})
const { data: progress = [] } = useQuery({
queryKey: ['flashcard-progress', user?.id, listId],
queryFn: () => fetchUserProgress(user!.id, listId),
enabled: !!user,
})
const progressMap: Record<number, UserProgress> = {}
progress.forEach(p => { progressMap[p.term_id] = p })
const getStatus = (termId: number): UserProgress['status'] =>
progressMap[termId]?.status ?? 'new'
const countAll = terms.length
const countNew = terms.filter(t => getStatus(t.id) === 'new').length
const countLearning = terms.filter(t => getStatus(t.id) === 'learning').length
const countKnown = terms.filter(t => getStatus(t.id) === 'known').length
const filtered = terms.filter(t => {
if (filter !== 'all' && getStatus(t.id) !== filter) return false
if (search.trim()) {
const q = search.toLowerCase()
return (
t.word.toLowerCase().includes(q) ||
(t.definition ?? '').toLowerCase().includes(q)
)
}
return true
})
return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
{/* Editorial head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div className="flex items-start gap-4 min-w-0">
<button
onClick={() => navigate({ to: '/flash-card' })}
className="w-10 h-10 flex-shrink-0 grid place-items-center rounded-xl transition-colors hover:bg-[var(--at-line-2)]"
style={{ color: 'var(--at-mute)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>arrow_back</span>
</button>
<div className="min-w-0">
<div className="at-eyebrow mb-2">Bộ thẻ từ vựng</div>
<h1 className="at-title text-[32px] lg:text-4xl">
{countAll} <i>từ</i>
</h1>
</div>
</div>
<button
onClick={() => navigate({ to: '/flash-card/$listId/learn', params: { listId: String(listId) } })}
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl text-[13.5px] font-semibold transition-opacity hover:opacity-90"
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 16, fontVariationSettings: "'FILL' 1" }}>play_arrow</span>
Bắt đu học
</button>
</div>
{/* Stats + filters */}
<div
className="rounded-2xl p-5 mb-6"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div className="flex flex-col md:flex-row md:items-center gap-4 justify-between mb-4">
<div className="relative flex-1 max-w-sm">
<span
className="material-symbols-outlined absolute left-3.5 top-1/2 -translate-y-1/2"
style={{ fontSize: 18, color: 'var(--at-mute)' }}
>
search
</span>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Tìm kiếm từ..."
className="w-full pl-10 pr-4 py-2.5 rounded-full text-sm focus:outline-none"
style={{
background: 'var(--at-paper-2)',
border: '1px solid var(--at-line)',
color: 'var(--at-ink)',
}}
/>
</div>
<div className="flex items-center gap-1.5 overflow-x-auto">
{(['all', 'new', 'learning', 'known', 'ignored'] as FilterStatus[]).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
'px-3.5 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-colors',
)}
style={{
background: filter === f ? 'var(--at-ink)' : 'var(--at-paper-2)',
color: filter === f ? 'var(--at-paper)' : 'var(--at-ink-2)',
border: '1px solid ' + (filter === f ? 'var(--at-ink)' : 'var(--at-line)'),
}}
>
{f === 'all' ? 'Tất cả' : STATUS_LABEL[f]}
</button>
))}
</div>
</div>
<div className="grid grid-cols-4 gap-2">
<HeadStat num={countAll} label="Tổng" />
<HeadStat num={countNew} label="Mới" />
<HeadStat num={countLearning} label="Đang học" color="var(--at-brand)" />
<HeadStat num={countKnown} label="Đã biết" color="var(--at-good)" />
</div>
</div>
{/* Terms list */}
{loadingTerms ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="rounded-xl h-20 animate-pulse"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
/>
))}
</div>
) : filtered.length === 0 ? (
<div
className="rounded-2xl p-12 text-center"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<p className="text-sm" style={{ color: 'var(--at-mute)' }}>Không tìm thấy từ nào phù hợp.</p>
</div>
) : (
<div className="space-y-2">
{filtered.map(term => (
<TermRow key={term.id} term={term} status={getStatus(term.id)} />
))}
</div>
)}
</div>
)
}
function HeadStat({ num, label, color }: { num: number; label: string; color?: string }) {
return (
<div className="text-center py-2 rounded-lg" style={{ background: 'var(--at-paper-2)' }}>
<div
className="at-serif"
style={{ fontSize: 22, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1, color: color ?? 'var(--at-ink)' }}
>
{num}
</div>
<div
className="mt-1"
style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 600 }}
>
{label}
</div>
</div>
)
}
function TermRow({ term, status }: { term: FlashcardTerm; status: UserProgress['status'] }) {
const imageSrc = resolveMediaUrl(term.image_url)
return (
<div
className="rounded-xl p-4 flex items-center gap-4 transition-shadow hover:shadow-sm"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
{imageSrc && (
<img
src={imageSrc}
alt={term.word}
loading="lazy"
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
style={{ background: 'var(--at-line-2)' }}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
/>
)}
<div className="w-1/4 min-w-0">
<div className="flex items-baseline gap-2 mb-1 flex-wrap">
<h3
className="at-serif text-[17px] tracking-tight truncate"
style={{ color: 'var(--at-ink)', fontWeight: 500 }}
>
{term.word}
</h3>
{term.phonetic && (
<span className="at-mono text-[11.5px] shrink-0" style={{ color: 'var(--at-mute)' }}>
{term.phonetic}
</span>
)}
</div>
{term.part_of_speech && (
<span
className="at-serif italic"
style={{ fontSize: 11, color: 'var(--at-mute-2)' }}
>
· {term.part_of_speech}
</span>
)}
</div>
<div className="flex-1 min-w-0 text-sm line-clamp-2" style={{ color: 'var(--at-ink-2)' }}>
{term.definition ?? '—'}
</div>
<span className={STATUS_CLASS[status]}>
<span className="at-chip-dot" />
{STATUS_LABEL[status]}
</span>
</div>
)
}

View File

@@ -0,0 +1,248 @@
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { FlashCard } from './FlashCard'
import { useVocabStore } from '@/store/vocab-store'
import { useVocab } from '@/hooks/use-vocab'
import { VOCAB_TOPICS } from '@/types'
import type { VocabTopic, VocabWord } from '@/types'
import { useRequireAuth } from '@/hooks/use-require-auth'
const GUEST_CARD_LIMIT = 5
export function Vocabulary() {
const { currentTopic, currentIndex, knownWords, setTopic, setCurrentIndex, markKnown, markNeedReview } = useVocabStore()
const [isFlipped, setIsFlipped] = useState(false)
const { isAuthenticated, requireAuth } = useRequireAuth()
const { data: allVocab = [], isLoading, isError } = useVocab()
const filtered: VocabWord[] = currentTopic === 'Tất cả'
? allVocab
: allVocab.filter((w) => w.topic === currentTopic)
const safeIndex = Math.min(currentIndex, Math.max(0, filtered.length - 1))
const word = filtered[safeIndex]
const knownInFiltered = filtered.filter((w) => knownWords.includes(w.id)).length
function handleSetTopic(topic: VocabTopic) {
setTopic(topic)
setCurrentIndex(0)
setIsFlipped(false)
}
function handlePrev() {
if (safeIndex > 0) {
setCurrentIndex(safeIndex - 1)
setIsFlipped(false)
}
}
function handleNext() {
if (safeIndex < filtered.length - 1) {
if (!isAuthenticated && safeIndex >= GUEST_CARD_LIMIT - 1) {
requireAuth()
return
}
setCurrentIndex(safeIndex + 1)
setIsFlipped(false)
}
}
function handleMarkKnown() {
if (!isAuthenticated && safeIndex >= GUEST_CARD_LIMIT - 1) {
requireAuth()
return
}
if (word) markKnown(word.id)
handleNext()
}
function handleMarkReview() {
if (!isAuthenticated && safeIndex >= GUEST_CARD_LIMIT - 1) {
requireAuth()
return
}
if (word) markNeedReview(word.id)
handleNext()
}
const recentKnown = knownWords
.map((id) => allVocab.find((w) => w.id === id))
.filter((w): w is VocabWord => w !== undefined)
.slice(-5)
.reverse()
return (
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
{/* Mobile topic chips */}
<div className="lg:hidden mb-4 overflow-x-auto pb-1">
<div className="flex gap-2 w-max">
{VOCAB_TOPICS.map((topic) => (
<button
key={topic}
onClick={() => handleSetTopic(topic)}
className={cn(
'px-4 py-2 rounded-full text-sm font-semibold whitespace-nowrap transition-colors',
currentTopic === topic
? 'bg-blue-600 text-white'
: 'bg-white border border-slate-200 text-slate-600 hover:border-blue-300',
)}
>
{topic}
</button>
))}
</div>
</div>
<div className="flex gap-5">
{/* Left: Topic menu — desktop only */}
<div className="hidden lg:block w-44 flex-shrink-0">
<div className="bg-white rounded-2xl border border-slate-200 p-3 sticky top-20">
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider px-2 mb-2">Chủ đ</div>
{VOCAB_TOPICS.map((topic) => (
<button
key={topic}
onClick={() => handleSetTopic(topic)}
className={cn(
'w-full text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-colors',
currentTopic === topic
? 'bg-blue-50 text-blue-600 font-semibold'
: 'text-slate-600 hover:bg-slate-50',
)}
>
{topic}
</button>
))}
</div>
</div>
{/* Center: Flashcard */}
<div className="flex-1 min-w-0">
{isLoading ? (
<div className="bg-white rounded-2xl border border-slate-200 p-10 flex flex-col items-center justify-center gap-3">
<div className="w-8 h-8 border-2 border-blue-100 border-t-blue-600 rounded-full animate-spin" />
<p className="text-sm text-slate-400">Đang tải từ vựng...</p>
</div>
) : isError ? (
<div className="bg-red-50 rounded-2xl border border-red-100 p-10 text-center">
<p className="text-sm text-red-500">Không thể tải từ vựng. Vui lòng thử lại.</p>
</div>
) : filtered.length === 0 ? (
<div className="bg-white rounded-2xl border border-slate-200 p-10 text-center">
<p className="text-slate-400">Không từ vựng cho chủ đ này.</p>
</div>
) : (
<>
{/* Progress */}
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-slate-400 font-medium">
{safeIndex + 1} / {filtered.length} từ
</span>
<span className="text-xs font-semibold text-blue-600">
{knownInFiltered}/{filtered.length} đã thuộc
</span>
</div>
<div className="h-1.5 w-full rounded-full bg-slate-200 mb-4">
<div
className="h-full bg-blue-600 rounded-full transition-all duration-300"
style={{ width: `${filtered.length > 0 ? ((safeIndex + 1) / filtered.length) * 100 : 0}%` }}
/>
</div>
{word && (
<FlashCard
word={word.word}
phonetic={word.phonetic}
meaningVi={word.meaningVi}
example={word.example}
topicBadge={word.topic}
isFlipped={isFlipped}
onFlip={() => setIsFlipped((v) => !v)}
/>
)}
{/* Navigation */}
<div className="flex items-center justify-between mt-4">
<button
onClick={handlePrev}
disabled={safeIndex === 0}
className="flex items-center gap-1.5 px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>chevron_left</span>
Trước
</button>
<div className="flex gap-2">
<button
onClick={handleMarkReview}
className="flex items-center gap-1.5 px-4 py-2.5 border border-amber-200 bg-amber-50 text-amber-700 rounded-xl text-sm font-semibold hover:bg-amber-100 transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>replay</span>
<span className="hidden sm:inline">Cần ôn</span>
</button>
<button
onClick={handleMarkKnown}
className="flex items-center gap-1.5 px-4 py-2.5 border border-green-200 bg-green-50 text-green-700 rounded-xl text-sm font-semibold hover:bg-green-100 transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>check</span>
<span className="hidden sm:inline">Đã thuộc</span>
</button>
</div>
<button
onClick={handleNext}
disabled={safeIndex === filtered.length - 1}
className="flex items-center gap-1.5 px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Tiếp
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>chevron_right</span>
</button>
</div>
</>
)}
</div>
{/* Right: Stats — desktop only */}
<div className="hidden lg:flex flex-col gap-4 w-52 flex-shrink-0">
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider mb-3">Thống </div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">Đã xem</span>
<span className="text-sm font-bold text-slate-800">{safeIndex + 1}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">Đã thuộc</span>
<span className="text-sm font-bold text-green-600">{knownWords.length}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">Tổng từ</span>
<span className="text-sm font-bold text-blue-600">{allVocab.length}</span>
</div>
</div>
<div className="mt-3 pt-3 border-t border-slate-100">
<div className="flex items-center gap-1.5">
<span className="material-symbols-outlined text-amber-500" style={{ fontSize: 16 }}>local_fire_department</span>
<span className="text-xs text-slate-500">Streak hôm nay</span>
</div>
<div className="text-2xl font-extrabold text-amber-500 mt-1">{Math.min(safeIndex + 1, 99)}</div>
</div>
</div>
{recentKnown.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider mb-3">Vừa thuộc</div>
<div className="space-y-2">
{recentKnown.map((w) => w && (
<div key={w.id} className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 flex-shrink-0" />
<span className="text-sm font-semibold text-slate-700 truncate">{w.word}</span>
<span className="text-xs text-slate-400 truncate ml-auto">{w.meaningVi}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,7 @@
const MEDIA_BASE_URL = 'https://study4.com'
export function resolveMediaUrl(path: string | null | undefined): string | null {
if (!path) return null
if (path.startsWith('http://') || path.startsWith('https://')) return path
return `${MEDIA_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`
}

View File

@@ -0,0 +1,30 @@
import type { UserProgress } from '../api/flashcard-api'
export const EASE = {
ignored: -1,
hard: 0.1,
easy: 0.65,
known: 1.0,
} as const
export type EaseKey = keyof typeof EASE
const INTERVAL_LADDER: Record<Exclude<EaseKey, 'ignored'>, number[]> = {
// count: 0 1 2 3 4 5+
known: [1, 3, 7, 14, 30, 60],
easy: [1, 2, 4, 8, 14, 30],
hard: [1, 1, 1, 2, 3, 5],
}
export function computeNextReview(key: EaseKey, reviewCount: number): string | null {
if (key === 'ignored') return null
const ladder = INTERVAL_LADDER[key]
const days = ladder[Math.min(reviewCount, ladder.length - 1)]
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString()
}
export function statusFor(key: EaseKey): UserProgress['status'] {
if (key === 'known') return 'known'
if (key === 'ignored') return 'ignored'
return 'learning'
}

View File

@@ -0,0 +1,298 @@
import { Link } from '@tanstack/react-router'
import { useUser } from '@/hooks/use-auth'
import { useAuthModalStore } from '@/store/auth-modal-store'
const FEATURES = [
{
to: '/toeic',
icon: 'assignment',
title: 'Luyện đề',
accent: 'TOEIC',
desc: 'Kho đề thi cập nhật theo cấu trúc mới nhất. Phân tích điểm yếu từng Part.',
stat: '350+ câu hỏi',
},
{
to: '/writing',
icon: 'auto_fix_high',
title: 'AI chấm',
accent: 'Writing',
desc: 'Phản hồi tức thì về ngữ pháp, từ vựng, cấu trúc và bài viết mẫu.',
stat: '3 lượt / ngày',
},
{
to: '/flash-card',
icon: 'menu_book',
title: 'Từ vựng',
accent: 'thông minh',
desc: 'Bộ thẻ TOEIC với spaced-repetition, lật 3D, ảnh minh hoạ.',
stat: '18 000+ từ',
},
]
export function Home() {
const user = useUser()
const openModal = useAuthModalStore((s) => s.open)
const firstName = user?.name ?? 'bạn'
return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
{/* Page head — editorial */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div>
<div className="at-eyebrow mb-3">Học TOEIC cùng AI</div>
<h1 className="at-title text-4xl lg:text-[44px]">
Chào <i>{firstName}</i>,<br />
hôm nay học <i>15 phút</i>?
</h1>
<div className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
Mục tiêu <b style={{ color: 'var(--at-ink)' }}>850</b>
<span className="mx-2 inline-block w-[3px] h-[3px] rounded-full align-middle" style={{ background: 'var(--at-mute-2)' }} />
hiện tại <b style={{ color: 'var(--at-ink)' }}>720</b>
<span className="mx-2 inline-block w-[3px] h-[3px] rounded-full align-middle" style={{ background: 'var(--at-mute-2)' }} />
còn <b style={{ color: 'var(--at-brand)' }}>130 điểm</b> nữa
</div>
</div>
<div className="flex gap-2.5">
<Link
to="/flash-card"
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl text-[13.5px] font-semibold transition-colors"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
>
Học từ vựng
</Link>
<Link
to="/toeic"
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl text-[13.5px] font-semibold transition-colors hover:opacity-90"
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)', border: '1px solid var(--at-ink)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>play_arrow</span>
Tiếp tục học
</Link>
</div>
</div>
<div className="grid lg:grid-cols-[2fr_1fr] gap-5">
{/* MAIN COL */}
<div className="flex flex-col gap-5 min-w-0">
{/* Progress hero */}
<div className="rounded-2xl p-7 flex flex-wrap items-center gap-7" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<ProgressRing value={85} />
<div className="flex-1 min-w-[240px]">
<div className="at-eyebrow mb-1">Lộ trình</div>
<div className="at-serif text-[22px] leading-[1.2] tracking-tight mb-3" style={{ color: 'var(--at-ink)' }}>
Bạn đang đi <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>đúng hướng</i> tuần này 4/7 ngày.
</div>
<div className="flex flex-wrap gap-4 items-stretch">
<Stat num="24" label="ngày còn lại" />
<div className="w-px self-stretch" style={{ background: 'var(--at-line)' }} />
<Stat num="+46" label="điểm tháng này" />
<div className="w-px self-stretch" style={{ background: 'var(--at-line)' }} />
<Stat num="68%" label="tỷ lệ đúng" color="var(--at-good)" />
</div>
</div>
</div>
{/* Feature cards */}
<div className="rounded-2xl p-6" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="at-eyebrow mb-1">Khám phá</div>
<h2 className="at-serif text-[22px] tracking-tight mb-5" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Tính năng <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>nổi bật</i>
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{FEATURES.map((f) => (
<Link
key={f.to}
to={f.to}
className="rounded-xl p-4 transition-all hover:-translate-y-0.5"
style={{ background: 'var(--at-paper-2)', border: '1px solid var(--at-line)' }}
>
<div className="flex items-center justify-between mb-3">
<div
className="w-9 h-9 rounded-lg grid place-items-center"
style={{ background: 'var(--at-brand-soft)', color: 'var(--at-brand)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>{f.icon}</span>
</div>
<span className="at-chip at-chip-brand">
<span className="at-chip-dot" />
{f.stat}
</span>
</div>
<div className="at-serif text-[17px] leading-[1.15] tracking-tight mb-1" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
{f.title} <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>{f.accent}</i>
</div>
<div className="text-[12.5px] leading-[1.5]" style={{ color: 'var(--at-mute)' }}>{f.desc}</div>
</Link>
))}
</div>
</div>
{/* 7-day journey */}
<div className="rounded-2xl p-6" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="flex justify-between items-end mb-4">
<div>
<div className="at-eyebrow mb-1">Tuần này</div>
<div className="at-serif text-[20px] tracking-tight" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Lộ trình <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>7 ngày</i>
</div>
</div>
<span className="at-chip at-chip-good">
<span className="at-chip-dot" />
+24% so với tuần trước
</span>
</div>
<div className="grid grid-cols-7 gap-2.5">
{['T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'CN'].map((d, i) => {
const h = [60, 85, 40, 90, 75, 0, 0][i]
const done = h > 0
const today = i === 4
return (
<div key={d} className="flex flex-col items-center gap-2">
<div className="w-full relative overflow-hidden rounded-[10px]" style={{ height: 96, background: 'var(--at-line-2)' }}>
<div
className="absolute left-0 right-0 bottom-0 rounded-[10px] transition-[height] duration-500"
style={{
height: `${h}%`,
background: today ? 'var(--at-brand)' : done ? 'var(--at-brand-soft)' : 'var(--at-line-2)',
}}
/>
</div>
<div
className={today ? 'at-serif italic' : ''}
style={{ fontSize: 11, color: today ? 'var(--at-brand)' : 'var(--at-mute)', fontWeight: today ? 700 : 500 }}
>
{d}
</div>
</div>
)
})}
</div>
</div>
</div>
{/* SIDE COL */}
<div className="flex flex-col gap-5">
{/* Streak card (inky) */}
<div className="rounded-2xl p-5" style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}>
<div className="flex items-center justify-between mb-3.5">
<div style={{ fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'rgba(250,248,243,0.55)', fontWeight: 600 }}>
Streak
</div>
<div className="w-10 h-10 rounded-xl grid place-items-center" style={{ background: 'rgba(255,255,255,0.08)', color: '#FFC27A' }}>
<span className="material-symbols-outlined" style={{ fontSize: 20, fontVariationSettings: "'FILL' 1" }}>local_fire_department</span>
</div>
</div>
<div className="at-serif" style={{ fontSize: 44, fontWeight: 400, letterSpacing: '-0.03em', lineHeight: 1, marginBottom: 4 }}>
7 <span className="italic opacity-65" style={{ fontSize: 18 }}>ngày</span>
</div>
<div style={{ fontSize: 12, color: 'rgba(250,248,243,0.55)', marginBottom: 14 }}>Kỷ lục: 21 ngày</div>
<div className="flex gap-1.5">
{['T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'CN'].map((d, i) => (
<div
key={i}
className="flex-1 rounded-md grid place-items-center"
style={{
height: 24,
background: i < 5 ? '#C15A34' : 'rgba(255,255,255,0.08)',
color: i < 5 ? 'white' : 'rgba(255,255,255,0.4)',
fontSize: 9,
fontWeight: 600,
}}
>
{d}
</div>
))}
</div>
</div>
{/* AI nudge */}
<div className="at-pullquote">
<div className="flex items-center gap-2 mb-2.5">
<div className="w-6 h-6 rounded-lg grid place-items-center" style={{ background: 'var(--at-brand)', color: 'white' }}>
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>auto_awesome</span>
</div>
<div style={{ fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--at-brand-ink)', fontWeight: 700 }}>
AI gợi ý
</div>
</div>
<div className="at-pullquote-q">
"Bạn yếu nhất <b style={{ fontWeight: 600 }}>Part 3</b> — dành 10 phút hôm nay có thể tăng <b style={{ fontWeight: 600 }}>30+ điểm</b>."
</div>
<div className="mt-2.5 text-[11px] opacity-70" style={{ color: 'var(--at-brand-ink)' }}> EnglishAI Coach</div>
</div>
{/* Pro tip */}
<div className="at-tip">
<div className="at-tip-label">Pro tip</div>
<div className="text-[12.5px] leading-[1.55]" style={{ color: 'var(--at-ink-2)' }}>
Học theo <b style={{ color: 'var(--at-warm)', fontWeight: 700 }}>cụm từ</b> (collocations) giúp bạn ghi nhớ nhanh hơn{' '}
<b style={{ color: 'var(--at-warm)', fontWeight: 700 }}>40%</b> so với học từ đơn lẻ.
</div>
</div>
{/* Guest CTA (only if not logged in) */}
{!user && (
<div className="rounded-2xl p-5" style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}>
<div className="at-eyebrow mb-2">Khách</div>
<div className="at-serif text-[17px] leading-[1.2] tracking-tight mb-3" style={{ color: 'var(--at-ink)', fontWeight: 500 }}>
Đăng đ <i style={{ color: 'var(--at-brand)', fontStyle: 'italic' }}>lưu tiến đ</i>.
</div>
<button
onClick={() => openModal('register')}
className="w-full py-2.5 rounded-xl text-[13.5px] font-semibold transition-opacity hover:opacity-90"
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
>
Đăng miễn phí
</button>
</div>
)}
</div>
</div>
</div>
)
}
function Stat({ num, label, color }: { num: string; label: string; color?: string }) {
return (
<div>
<div className="at-serif" style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1, color: color ?? 'var(--at-ink)' }}>
{num}
</div>
<div style={{ fontSize: 11, color: 'var(--at-mute)', marginTop: 4 }}>{label}</div>
</div>
)
}
function ProgressRing({ value }: { value: number }) {
const r = 58
const c = 2 * Math.PI * r
const offset = c - (value / 100) * c
return (
<div className="relative grid place-items-center" style={{ width: 132, height: 132 }}>
<svg width="132" height="132">
<circle cx="66" cy="66" r={r} fill="none" stroke="var(--at-line-2)" strokeWidth="7" />
<circle
cx="66"
cy="66"
r={r}
fill="none"
stroke="var(--at-brand)"
strokeWidth="7"
strokeDasharray={c}
strokeDashoffset={offset}
strokeLinecap="round"
transform="rotate(-90 66 66)"
style={{ transition: 'stroke-dashoffset 0.6s cubic-bezier(0.2, 0.7, 0.2, 1)' }}
/>
</svg>
<div className="absolute text-center">
<div className="at-serif" style={{ fontSize: 34, fontWeight: 400, letterSpacing: '-0.025em', lineHeight: 1, color: 'var(--at-ink)' }}>
720
</div>
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', letterSpacing: '0.12em', marginTop: 4, fontWeight: 600 }}>
/ 850
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,116 @@
import { useState } from 'react'
import { useAuthStore } from '@/store/auth-store'
import { supabase } from '@/lib/supabase'
import { cn } from '@/lib/utils'
export function AccountCard() {
const logout = useAuthStore((s) => s.logout)
const [changingPw, setChangingPw] = useState(false)
const [pw, setPw] = useState({ current: '', next: '', confirm: '' })
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
const [confirmDelete, setConfirmDelete] = useState(false)
async function changePassword() {
if (pw.next !== pw.confirm) { setMsg('Mật khẩu xác nhận không khớp.'); return }
if (pw.next.length < 6) { setMsg('Mật khẩu phải có ít nhất 6 ký tự.'); return }
setSaving(true)
setMsg('')
try {
const { error } = await supabase.auth.updateUser({ password: pw.next })
if (error) throw error
setMsg('Đổi mật khẩu thành công!')
setChangingPw(false)
setPw({ current: '', next: '', confirm: '' })
} catch {
setMsg('Không thể đổi mật khẩu. Thử lại sau.')
} finally {
setSaving(false)
}
}
async function deleteAccount() {
// Supabase doesn't support self-delete via client SDK — log out and show message
await logout()
alert('Vui lòng liên hệ hỗ trợ để xóa tài khoản.')
}
return (
<section className="col-span-12 bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-lg font-bold mb-5 flex items-center gap-2 text-slate-800">
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>security</span>
Tài khoản &amp; Bảo mật
</h2>
{/* Change password */}
<div className="flex items-center justify-between py-4 border-b border-slate-100">
<div>
<p className="font-semibold text-slate-800 text-sm">Mật khẩu</p>
<p className="text-xs text-slate-400 mt-0.5">Cập nhật mật khẩu đ bảo mật tài khoản</p>
</div>
<button
onClick={() => { setChangingPw(!changingPw); setMsg('') }}
className="px-5 py-2 rounded-full border border-slate-200 text-sm font-bold hover:bg-slate-50 transition-colors"
>
Đi mật khẩu
</button>
</div>
{changingPw && (
<div className="py-4 border-b border-slate-100 space-y-3">
{(['next', 'confirm'] as const).map((field) => (
<input
key={field}
type="password"
placeholder={field === 'next' ? 'Mật khẩu mới' : 'Xác nhận mật khẩu mới'}
value={pw[field]}
onChange={(e) => setPw((p) => ({ ...p, [field]: e.target.value }))}
className="w-full max-w-sm border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
/>
))}
<div className="flex gap-2">
<button
onClick={changePassword}
disabled={saving}
className={cn('px-5 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold hover:bg-blue-700 transition-colors', saving && 'opacity-50')}
>
{saving ? 'Đang lưu...' : 'Lưu'}
</button>
<button onClick={() => { setChangingPw(false); setMsg('') }} className="px-4 py-2 text-slate-400 text-sm">
Huỷ
</button>
</div>
{msg && <p className={cn('text-sm', msg.includes('thành công') ? 'text-green-600' : 'text-red-600')}>{msg}</p>}
</div>
)}
{/* Danger zone */}
<div className="mt-6">
<p className="text-xs font-bold text-red-500 uppercase tracking-wider mb-3">Khu vực nguy hiểm</p>
<div className="flex items-center justify-between p-4 bg-red-50 rounded-xl border border-red-100">
<div>
<p className="font-semibold text-red-600 text-sm">Xóa tài khoản</p>
<p className="text-xs text-red-400 mt-0.5">Hành đng này không thể hoàn tác. Toàn bộ dữ liệu học tập sẽ bị mất.</p>
</div>
{confirmDelete ? (
<div className="flex gap-2 ml-4 flex-shrink-0">
<button onClick={deleteAccount} className="px-4 py-2 bg-red-600 text-white rounded-full text-sm font-bold hover:bg-red-700 transition-colors">
Xác nhận
</button>
<button onClick={() => setConfirmDelete(false)} className="px-4 py-2 text-slate-500 text-sm">
Huỷ
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(true)}
className="ml-4 flex-shrink-0 px-5 py-2 bg-red-600 text-white rounded-full text-sm font-bold shadow-sm hover:bg-red-700 transition-colors"
>
Xóa tài khoản
</button>
)}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,56 @@
import { useState } from 'react'
import { cn } from '@/lib/utils'
const GOAL_OPTIONS = [
{ value: '10', label: '10p', sublabel: 'Dễ dàng' },
{ value: '20', label: '20p', sublabel: 'Tiêu chuẩn' },
{ value: '30', label: '30p', sublabel: 'Thử thách' },
{ value: '60', label: '1h', sublabel: 'Chuyên sâu' },
]
const XP_MAP: Record<string, number> = { '10': 20, '20': 50, '30': 80, '60': 120 }
const STORAGE_KEY = 'settings_daily_goal'
export function DailyGoalCard() {
const [goal, setGoal] = useState<string>(() => localStorage.getItem(STORAGE_KEY) ?? '20')
function handleSelect(value: string) {
setGoal(value)
localStorage.setItem(STORAGE_KEY, value)
}
return (
<section className="col-span-12 md:col-span-6 bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-lg font-bold mb-5 flex items-center gap-2 text-slate-800">
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>target</span>
Mục tiêu hàng ngày
</h2>
<div className="grid grid-cols-2 gap-3">
{GOAL_OPTIONS.map((opt) => {
const active = goal === opt.value
return (
<button
key={opt.value}
onClick={() => handleSelect(opt.value)}
className={cn(
'p-4 rounded-xl border-2 text-center transition-all',
active
? 'border-blue-600 bg-blue-50 text-blue-600'
: 'border-slate-100 bg-slate-50 text-slate-700 hover:border-slate-200',
)}
>
<span className={cn('block text-sm font-bold', active && 'text-blue-600')}>{opt.label}</span>
<span className={cn('text-xs', active ? 'text-blue-500' : 'text-slate-400')}>{opt.sublabel}</span>
</button>
)
})}
</div>
<div className="mt-5 p-3.5 rounded-xl bg-blue-50 flex items-center justify-center gap-2 text-blue-600 font-bold text-sm">
<span className="material-symbols-outlined" style={{ fontSize: 18, fontVariationSettings: "'FILL' 1" }}>stars</span>
+{XP_MAP[goal]} XP mỗi ngày
</div>
</section>
)
}

View File

@@ -0,0 +1,86 @@
import { useState } from 'react'
const STORAGE_KEY = 'settings_exam_date'
function getDaysUntil(dateStr: string): number {
const today = new Date()
today.setHours(0, 0, 0, 0)
const target = new Date(dateStr)
target.setHours(0, 0, 0, 0)
return Math.ceil((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
}
function formatVi(dateStr: string): string {
const d = new Date(dateStr)
return d.toLocaleDateString('vi-VN', { day: 'numeric', month: 'long', year: 'numeric' })
}
export function ExamDateCard() {
const [examDate, setExamDate] = useState<string>(() => localStorage.getItem(STORAGE_KEY) ?? '')
const [editing, setEditing] = useState(false)
const [input, setInput] = useState(examDate)
function save() {
if (!input) return
setExamDate(input)
localStorage.setItem(STORAGE_KEY, input)
setEditing(false)
}
const days = examDate ? getDaysUntil(examDate) : null
return (
<section className="col-span-12 md:col-span-6 bg-white rounded-xl p-6 shadow-sm flex flex-col">
<h2 className="text-lg font-bold mb-5 flex items-center gap-2 text-slate-800">
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>calendar_month</span>
Ngày thi TOEIC
</h2>
<div className="flex-1 flex flex-col justify-center">
{examDate && days !== null ? (
<>
<div className="bg-gradient-to-br from-blue-600 to-blue-500 rounded-xl p-6 text-white text-center shadow-lg shadow-blue-200">
<p className="text-sm font-medium opacity-80 mb-1">Đếm ngược kỳ thi</p>
<p className="text-4xl font-extrabold tracking-tight">
{days > 0 ? `Còn ${days} ngày` : days === 0 ? 'Hôm nay!' : 'Đã qua'}
</p>
<div className="mt-3 inline-flex items-center gap-1.5 px-3 py-1 bg-white/20 rounded-full text-xs">
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>event</span>
<span>{formatVi(examDate)}</span>
</div>
</div>
<button
onClick={() => { setInput(examDate); setEditing(true) }}
className="mt-4 w-full py-2.5 rounded-full border border-blue-200 text-blue-600 font-bold text-sm hover:bg-blue-50 transition-colors"
>
Thay đi ngày thi
</button>
</>
) : (
<div className="text-center py-6">
<span className="material-symbols-outlined text-slate-300 mb-2" style={{ fontSize: 48 }}>event_upcoming</span>
<p className="text-slate-400 text-sm mb-4">Chưa đt ngày thi</p>
</div>
)}
{editing || !examDate ? (
<div className="mt-3 flex gap-2">
<input
type="date"
value={input}
onChange={(e) => setInput(e.target.value)}
min={new Date().toISOString().split('T')[0]}
className="flex-1 border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
/>
<button onClick={save} disabled={!input} className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold disabled:opacity-40 hover:bg-blue-700 transition-colors">
Lưu
</button>
{editing && (
<button onClick={() => setEditing(false)} className="px-3 py-2 text-slate-400 text-sm">Huỷ</button>
)}
</div>
) : null}
</div>
</section>
)
}

View File

@@ -0,0 +1,101 @@
import { useState } from 'react'
import { cn } from '@/lib/utils'
interface NotifPrefs {
daily: boolean
streak: boolean
weekly: boolean
leaderboard: boolean
}
const STORAGE_KEY = 'settings_notifications'
const TIME_KEY = 'settings_notif_time'
const DEFAULT_PREFS: NotifPrefs = { daily: true, streak: true, weekly: false, leaderboard: true }
function loadPrefs(): NotifPrefs {
try {
return { ...DEFAULT_PREFS, ...JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') }
} catch {
return DEFAULT_PREFS
}
}
interface ToggleProps {
checked: boolean
onChange: (v: boolean) => void
}
function Toggle({ checked, onChange }: ToggleProps) {
return (
<button
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
'relative w-11 h-6 rounded-full transition-colors flex-shrink-0',
checked ? 'bg-blue-600' : 'bg-slate-200',
)}
>
<span className={cn(
'absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform',
checked ? 'translate-x-5' : 'translate-x-0.5',
)} />
</button>
)
}
export function NotificationsCard() {
const [prefs, setPrefs] = useState<NotifPrefs>(loadPrefs)
const [time, setTime] = useState(() => localStorage.getItem(TIME_KEY) ?? '20:00')
function toggle(key: keyof NotifPrefs) {
const next = { ...prefs, [key]: !prefs[key] }
setPrefs(next)
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
}
function saveTime(v: string) {
setTime(v)
localStorage.setItem(TIME_KEY, v)
}
const items: { key: keyof NotifPrefs; label: string; desc: string }[] = [
{ key: 'daily', label: 'Nhắc nhở hàng ngày', desc: 'Tùy chỉnh thời gian học mỗi ngày' },
{ key: 'streak', label: 'Cảnh báo chuỗi học tập', desc: 'Không bao giờ bỏ lỡ Streak của bạn' },
{ key: 'weekly', label: 'Nhắc nhở mục tiêu tuần', desc: 'Theo dõi tiến độ học tập hàng tuần' },
{ key: 'leaderboard', label: 'Cập nhật bảng xếp hạng', desc: 'Biết ngay khi ai đó vượt qua bạn' },
]
return (
<section className="col-span-12 bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-lg font-bold mb-6 flex items-center gap-2 text-slate-800">
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>notifications_active</span>
Cài đt thông báo
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-10 gap-y-6">
{items.map((item) => (
<div key={item.key} className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-slate-800 text-sm">{item.label}</p>
<p className="text-xs text-slate-400 mt-0.5">{item.desc}</p>
{item.key === 'daily' && prefs.daily && (
<div className="mt-2 inline-flex items-center gap-1.5 px-2.5 py-1 bg-slate-100 rounded-lg text-xs font-semibold text-slate-600">
<span className="material-symbols-outlined" style={{ fontSize: 13 }}>schedule</span>
<input
type="time"
value={time}
onChange={(e) => saveTime(e.target.value)}
className="bg-transparent outline-none text-xs font-semibold w-16"
/>
</div>
)}
</div>
<Toggle checked={prefs[item.key]} onChange={() => toggle(item.key)} />
</div>
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,126 @@
import { useState } from 'react'
import { useAuthStore } from '@/store/auth-store'
import { supabase } from '@/lib/supabase'
import { cn } from '@/lib/utils'
export function ProfileCard() {
const user = useAuthStore((s) => s.user)
const [editingName, setEditingName] = useState(false)
const [editingEmail, setEditingEmail] = useState(false)
const [nameInput, setNameInput] = useState(user?.name ?? '')
const [emailInput, setEmailInput] = useState(user?.email ?? '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
async function saveName() {
if (!nameInput.trim()) return
setSaving(true)
setError('')
try {
const { error: err } = await supabase.auth.updateUser({ data: { name: nameInput.trim() } })
if (err) throw err
setEditingName(false)
} catch {
setError('Không thể lưu tên. Thử lại sau.')
} finally {
setSaving(false)
}
}
async function saveEmail() {
if (!emailInput.trim()) return
setSaving(true)
setError('')
try {
const { error: err } = await supabase.auth.updateUser({ email: emailInput.trim() })
if (err) throw err
setEditingEmail(false)
} catch {
setError('Không thể lưu email. Thử lại sau.')
} finally {
setSaving(false)
}
}
const initials = (user?.name ?? 'U').charAt(0).toUpperCase()
return (
<section className="col-span-12 md:col-span-8 bg-white rounded-xl p-6 shadow-sm">
<h2 className="text-lg font-bold mb-5 flex items-center gap-2 text-slate-800">
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>person</span>
Hồ nhân
</h2>
<div className="flex items-center gap-6">
{/* Avatar */}
<div className="relative flex-shrink-0">
<div className="w-20 h-20 rounded-full bg-blue-600 flex items-center justify-center text-white text-2xl font-bold">
{initials}
</div>
</div>
{/* Fields */}
<div className="flex-1 space-y-3">
{/* Name */}
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<div className="flex-1">
<p className="text-xs text-slate-400 font-semibold uppercase tracking-wider">Họ tên</p>
{editingName ? (
<input
autoFocus
value={nameInput}
onChange={(e) => setNameInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') setEditingName(false) }}
className="mt-0.5 text-base font-semibold w-full border border-blue-300 rounded-lg px-2 py-0.5 outline-none focus:ring-2 focus:ring-blue-200"
/>
) : (
<p className="text-base font-semibold text-slate-800 mt-0.5">{user?.name ?? '—'}</p>
)}
</div>
{editingName ? (
<div className="flex gap-2 ml-3">
<button onClick={saveName} disabled={saving} className={cn('text-blue-600 font-bold text-sm', saving && 'opacity-50')}>
{saving ? 'Lưu...' : 'Lưu'}
</button>
<button onClick={() => setEditingName(false)} className="text-slate-400 text-sm">Huỷ</button>
</div>
) : (
<button onClick={() => setEditingName(true)} className="ml-3 text-blue-600 font-bold text-sm hover:underline">Chỉnh sửa</button>
)}
</div>
{/* Email */}
<div className="flex items-center justify-between py-2">
<div className="flex-1">
<p className="text-xs text-slate-400 font-semibold uppercase tracking-wider">Email</p>
{editingEmail ? (
<input
autoFocus
type="email"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveEmail(); if (e.key === 'Escape') setEditingEmail(false) }}
className="mt-0.5 text-base font-semibold w-full border border-blue-300 rounded-lg px-2 py-0.5 outline-none focus:ring-2 focus:ring-blue-200"
/>
) : (
<p className="text-base font-semibold text-slate-800 mt-0.5">{user?.email ?? '—'}</p>
)}
</div>
{editingEmail ? (
<div className="flex gap-2 ml-3">
<button onClick={saveEmail} disabled={saving} className={cn('text-blue-600 font-bold text-sm', saving && 'opacity-50')}>
{saving ? 'Lưu...' : 'Lưu'}
</button>
<button onClick={() => setEditingEmail(false)} className="text-slate-400 text-sm">Huỷ</button>
</div>
) : (
<button onClick={() => setEditingEmail(true)} className="ml-3 text-blue-600 font-bold text-sm hover:underline">Chỉnh sửa</button>
)}
</div>
</div>
</div>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
</section>
)
}

View File

@@ -0,0 +1,49 @@
import { useAuthStore } from '@/store/auth-store'
import { useAuthModalStore } from '@/store/auth-modal-store'
import { ProfileCard } from './ProfileCard'
import { XuWalletCard } from './XuWalletCard'
import { DailyGoalCard } from './DailyGoalCard'
import { ExamDateCard } from './ExamDateCard'
import { NotificationsCard } from './NotificationsCard'
import { AccountCard } from './AccountCard'
export function Settings() {
const user = useAuthStore((s) => s.user)
const openModal = useAuthModalStore((s) => s.open)
if (!user) {
return (
<div className="px-4 lg:px-6 py-12 max-w-6xl mx-auto flex flex-col items-center justify-center gap-4 text-center">
<span className="material-symbols-outlined text-slate-300" style={{ fontSize: 64 }}>settings</span>
<h1 className="text-xl font-bold text-slate-700">Cài đt</h1>
<p className="text-slate-400 text-sm max-w-xs">
Đăng nhập đ nhân hoá mục tiêu học tập, cài đt thông báo quản tài khoản.
</p>
<button
onClick={() => openModal('login')}
className="mt-2 px-6 py-2.5 bg-blue-600 text-white rounded-full font-bold text-sm hover:bg-blue-700 transition-colors"
>
Đăng nhập
</button>
</div>
)
}
return (
<div className="px-4 lg:px-6 py-6 max-w-5xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">Cài đt</h1>
<p className="text-slate-400 text-sm">Quản hồ , mục tiêu học tập thông báo.</p>
</div>
<div className="grid grid-cols-12 gap-5">
<ProfileCard />
<XuWalletCard />
<DailyGoalCard />
<ExamDateCard />
<NotificationsCard />
<AccountCard />
</div>
</div>
)
}

View File

@@ -0,0 +1,68 @@
import { useXuTransactions } from '@/hooks/use-gamification'
import { useGamification } from '@/hooks/use-gamification'
import type { XuTransactionType } from '@/types'
const TX_ICON: Record<XuTransactionType, string> = {
earn_welcome: 'redeem',
earn_daily: 'check_circle',
earn_streak: 'local_fire_department',
earn_ads: 'play_circle',
spend_freeze: 'ac_unit',
spend_writing: 'auto_fix_high',
spend_test: 'quiz',
}
export function XuWalletCard() {
const { data: gam } = useGamification()
const { data: txs, isLoading } = useXuTransactions(5)
const balance = gam?.xu ?? 0
return (
<section className="col-span-12 md:col-span-4 bg-blue-600 text-white rounded-xl p-6 relative overflow-hidden flex flex-col justify-between shadow-sm">
{/* Decorative blobs */}
<div className="absolute -right-6 -top-6 w-28 h-28 bg-white/10 rounded-full blur-3xl pointer-events-none" />
<div className="absolute -left-6 -bottom-6 w-28 h-28 bg-blue-400/30 rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10">
<p className="text-xs font-bold uppercase tracking-widest opacity-70 mb-1"> Xu của bạn</p>
<div className="text-4xl font-extrabold tracking-tight">
{balance.toLocaleString('vi-VN')} Xu
</div>
</div>
<div className="relative z-10 mt-5 space-y-2.5">
{isLoading && (
<>
{[1, 2].map((i) => (
<div key={i} className="h-10 bg-white/10 rounded-lg animate-pulse" />
))}
</>
)}
{!isLoading && txs && txs.length === 0 && (
<div className="bg-white/10 rounded-lg px-3 py-2.5 text-xs opacity-70 text-center">
Chưa giao dịch nào
</div>
)}
{!isLoading && txs && txs.map((t) => (
<div
key={t.id}
className="bg-white/10 backdrop-blur-md rounded-lg px-3 py-2.5 flex items-center justify-between text-xs"
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>
{TX_ICON[t.type] ?? 'swap_horiz'}
</span>
<span className="truncate max-w-[120px]">{t.description ?? t.type}</span>
</div>
<span className={`font-bold ${t.amount > 0 ? 'text-green-300' : 'text-red-300'}`}>
{t.amount > 0 ? `+${t.amount}` : t.amount} Xu
</span>
</div>
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,53 @@
import { supabase } from '@/lib/supabase'
import type { TestRecord, PartRecord } from '@/types'
export async function fetchTests(): Promise<TestRecord[]> {
const { data, error } = await supabase
.from('test')
.select('id, title, description, total_questions, duration_minutes, test_category(name)')
.order('id')
if (error) throw error
return (data ?? []).map((row: Record<string, unknown>) => ({
id: row.id as number,
title: row.title as string,
description: row.description as string | null,
totalQuestions: row.total_questions as number,
durationMinutes: row.duration_minutes as number,
categoryName: (row.test_category as { name: string } | null)?.name ?? null,
}))
}
export async function fetchTestWithParts(testId: number): Promise<{ test: TestRecord; parts: PartRecord[] }> {
const { data: testRow, error: testErr } = await supabase
.from('test')
.select('id, title, description, total_questions, duration_minutes, test_category(name)')
.eq('id', testId)
.single()
if (testErr) throw testErr
const { data: partRows, error: partErr } = await supabase
.from('part')
.select('id, test_id, part_number, title, question_count')
.eq('test_id', testId)
.order('part_number')
if (partErr) throw partErr
const row = testRow as Record<string, unknown>
return {
test: {
id: row.id as number,
title: row.title as string,
description: row.description as string | null,
totalQuestions: row.total_questions as number,
durationMinutes: row.duration_minutes as number,
categoryName: (row.test_category as { name: string } | null)?.name ?? null,
},
parts: (partRows ?? []).map((p: Record<string, unknown>) => ({
id: p.id as number,
testId: p.test_id as number,
partNumber: p.part_number as number,
title: p.title as string,
questionCount: p.question_count as number,
})),
}
}

View File

@@ -0,0 +1,29 @@
interface QuestionCardProps {
question?: string
options?: string[]
selectedAnswer?: string
onSelect?: (answer: string) => void
}
export function QuestionCard({ question, options, selectedAnswer, onSelect }: QuestionCardProps) {
return (
<div className="rounded-lg border p-4 space-y-3">
<p className="font-medium">{question || "Question placeholder"}</p>
{options && (
<ul className="space-y-2">
{options.map((opt) => (
<li
key={opt}
onClick={() => onSelect?.(opt[0])}
className={`rounded border p-2 text-sm cursor-pointer hover:bg-gray-50 ${
selectedAnswer === opt[0] ? "border-blue-500 bg-blue-50" : ""
}`}
>
{opt}
</li>
))}
</ul>
)}
</div>
)
}

View File

@@ -0,0 +1,183 @@
import { useEffect, useRef } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import { useTestStore } from '@/store/test-store'
import { useRequireAuth } from '@/hooks/use-require-auth'
import { useAuthStore } from '@/store/auth-store'
import { saveTestResult } from '@/lib/progress-service'
import { useAwardActivity } from '@/hooks/use-gamification'
import { XP_REWARDS } from '@/lib/gamification-service'
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
function formatTime(s: number) {
const m = Math.floor(s / 60)
const sec = s % 60
if (m === 0) return `${sec}s`
return `${m}m ${sec}s`
}
export function TestResult() {
const navigate = useNavigate()
const { testId, testName, parts, answers, timeUsed, reset } = useTestStore()
const { isAuthenticated, isLoading } = useRequireAuth()
const user = useAuthStore((s) => s.user)
const savedRef = useRef(false)
const { mutate: awardActivity } = useAwardActivity()
// Flatten all questions across parts
const allQuestions = parts.flatMap(p => p.questions)
useEffect(() => {
if (isLoading) return
if (!isAuthenticated) navigate({ to: '/toeic' })
}, [isLoading, isAuthenticated, navigate])
useEffect(() => {
if (!user || savedRef.current || allQuestions.length === 0) return
savedRef.current = true
const correct = allQuestions.filter(q => answers[q.id] === q.correctAnswer).length
awardActivity({ xp: XP_REWARDS.test })
saveTestResult(user.id, {
testId,
selectedParts: parts.map(p => p.partNumber),
score: correct,
total: allQuestions.length,
timeUsed,
answers: allQuestions.map(q => ({
questionId: q.id,
selected: answers[q.id] ?? null,
correct: answers[q.id] === q.correctAnswer,
})),
})
}, [user, allQuestions.length])
if (allQuestions.length === 0) {
return (
<div className="px-6 py-8 max-w-6xl mx-auto text-center">
<p className="text-slate-500 mb-4">Không dữ liệu bài thi.</p>
<button onClick={() => navigate({ to: '/toeic' })}
className="bg-blue-600 text-white px-6 py-2.5 rounded-xl font-semibold text-sm hover:bg-blue-700 transition-colors">
Chọn đ thi
</button>
</div>
)
}
const correct = allQuestions.filter(q => answers[q.id] === q.correctAnswer).length
const wrong = allQuestions.filter(q => answers[q.id] !== null && answers[q.id] !== undefined && answers[q.id] !== q.correctAnswer).length
const skipped = allQuestions.filter(q => answers[q.id] === null || answers[q.id] === undefined).length
const total = allQuestions.length
const percent = total > 0 ? Math.round((correct / total) * 100) : 0
const circumference = 2 * Math.PI * 52
const offset = circumference - (percent / 100) * circumference
return (
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
{/* Score header */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 mb-5">
<div className="flex flex-col lg:flex-row items-center gap-6">
<div className="flex-shrink-0 relative w-32 h-32">
<svg className="w-full h-full -rotate-90" viewBox="0 0 120 120">
<circle cx="60" cy="60" r="52" fill="none" stroke="#e2e8f0" strokeWidth="8" />
<circle cx="60" cy="60" r="52" fill="none"
stroke={percent >= 70 ? '#16a34a' : percent >= 50 ? '#2563eb' : '#dc2626'}
strokeWidth="8" strokeLinecap="round"
strokeDasharray={circumference} strokeDashoffset={offset}
className="transition-all duration-700" />
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-extrabold text-slate-800">{correct}/{total}</span>
<span className="text-xs text-slate-400 font-medium">điểm</span>
</div>
</div>
<div className="flex-1 text-center lg:text-left">
<div className="text-2xl font-extrabold text-slate-800 mb-1">
{percent >= 80 ? 'Xuất sắc!' : percent >= 60 ? 'Hoàn thành!' : 'Cố gắng hơn nhé!'}
</div>
<div className="text-sm text-slate-400 mb-4">{testName}</div>
<div className="flex flex-wrap gap-3 justify-center lg:justify-start">
{[
{ label: 'Đúng', value: correct, cls: 'bg-green-50 border-green-100 text-green-600' },
{ label: 'Sai', value: wrong, cls: 'bg-red-50 border-red-100 text-red-600' },
{ label: 'Bỏ qua', value: skipped, cls: 'bg-slate-50 border-slate-200 text-slate-500' },
{ label: 'Thời gian', value: formatTime(timeUsed), cls: 'bg-blue-50 border-blue-100 text-blue-600' },
].map(({ label, value, cls }) => (
<div key={label} className={cn('border rounded-xl px-4 py-2 text-center', cls)}>
<div className="text-xl font-extrabold">{value}</div>
<div className="text-xs text-slate-400">{label}</div>
</div>
))}
</div>
</div>
<div className="flex lg:flex-col gap-3 flex-shrink-0">
<button onClick={() => navigate({ to: '/toeic/session' })}
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 transition-colors">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>replay</span>Làm lại
</button>
<button onClick={() => { reset(); navigate({ to: '/toeic' }) }}
className="flex items-center gap-2 px-5 py-2.5 border border-slate-200 text-slate-600 rounded-xl text-sm font-semibold hover:bg-slate-50 transition-colors">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>home</span>Về trang chủ
</button>
</div>
</div>
</div>
{/* Answer review grouped by part */}
{parts.map(part => (
<div key={part.partNumber} className="bg-white rounded-2xl border border-slate-200 p-6 mb-4">
<h2 className="text-base font-bold text-slate-800 mb-4">Part {part.partNumber} {part.partName}</h2>
<div className="space-y-4">
{part.questions.map((q, i) => {
const userAnswer = answers[q.id] ?? null
const isCorrect = userAnswer === q.correctAnswer
const isSkipped = userAnswer === null || userAnswer === undefined
return (
<div key={q.id} className={cn(
'rounded-xl border p-4',
isCorrect ? 'border-green-100 bg-green-50/50' : isSkipped ? 'border-slate-100 bg-slate-50/50' : 'border-red-100 bg-red-50/50',
)}>
<div className="flex items-start gap-3">
<span className={cn(
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5',
isCorrect ? 'bg-green-600 text-white' : isSkipped ? 'bg-slate-400 text-white' : 'bg-red-600 text-white',
)}>{i + 1}</span>
<div className="flex-1 min-w-0">
{q.text && <p className="text-sm font-medium text-slate-800 mb-2">{q.text}</p>}
<div className="flex flex-wrap gap-2 mb-2">
{q.options.map((opt, j) => (
<span key={j} className={cn(
'text-xs px-2.5 py-1 rounded-lg font-medium',
j === q.correctAnswer ? 'bg-green-100 text-green-700 border border-green-200'
: j === userAnswer && !isCorrect ? 'bg-red-100 text-red-700 border border-red-200 line-through'
: 'bg-slate-100 text-slate-500',
)}>
{ANSWER_LABELS[j]}. {opt}
</span>
))}
</div>
{q.explanation && (
<p className="text-xs text-slate-500 bg-white rounded-lg px-3 py-2 border border-slate-100">
<span className="font-semibold text-slate-600">Giải thích: </span>{q.explanation}
</p>
)}
</div>
<span className="flex-shrink-0">
{isCorrect
? <span className="material-symbols-outlined text-green-600" style={{ fontSize: 20 }}>check_circle</span>
: isSkipped
? <span className="material-symbols-outlined text-slate-400" style={{ fontSize: 20 }}>remove_circle</span>
: <span className="material-symbols-outlined text-red-500" style={{ fontSize: 20 }}>cancel</span>}
</span>
</div>
</div>
)
})}
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,151 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import { useTestStore } from '@/store/test-store'
import { useRequireAuth } from '@/hooks/use-require-auth'
import { TestSessionHeader } from './TestSessionHeader'
import { TestSessionSidebar } from './TestSessionSidebar'
import { TestSessionFooter } from './TestSessionFooter'
import type { Question } from '@/types'
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
function QuestionCard({
question, globalNum, answer, onSelect,
}: {
question: Question
globalNum: number
answer: number | null
onSelect: (idx: number) => void
}) {
return (
<div className="bg-white rounded-2xl border border-slate-200 p-6 mb-4">
<span className="inline-block bg-blue-600 text-white text-xs font-bold px-3 py-1 rounded-full mb-4">
Câu {globalNum}
</span>
{question.passageText && (
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4 mb-4 text-sm text-slate-700 leading-relaxed whitespace-pre-wrap">
{question.passageText}
</div>
)}
{question.audioUrl && (
<audio controls src={question.audioUrl} className="w-full mb-4 rounded-lg" />
)}
{question.imageUrl && (
<img src={question.imageUrl} alt="" className="max-h-64 rounded-xl mb-4 object-contain" />
)}
{question.text && (
<p className="text-base font-medium text-slate-800 leading-relaxed mb-5">{question.text}</p>
)}
<div className="space-y-2.5">
{question.options.map((opt, i) => (
<button
key={i}
onClick={() => onSelect(i)}
className={cn(
'w-full flex items-center gap-3 p-3.5 border-2 rounded-xl text-sm font-medium text-left transition-all',
answer === i
? 'border-blue-600 bg-blue-50 text-blue-700'
: 'border-slate-200 hover:border-blue-300 hover:bg-blue-50/50 text-slate-700',
)}
>
<span className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0',
answer === i ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-500',
)}>
{ANSWER_LABELS[i]}
</span>
{opt}
</button>
))}
</div>
</div>
)
}
export function TestSession() {
const navigate = useNavigate()
const { testName, parts, currentPartIndex, answers, totalSeconds, setAnswer, setCurrentPart, submitExam } = useTestStore()
const { isAuthenticated, isLoading } = useRequireAuth()
const [timeLeft, setTimeLeft] = useState(() => totalSeconds > 0 ? totalSeconds : -1)
const [timeUsed, setTimeUsed] = useState(0)
const handleSubmit = useCallback(() => {
submitExam(totalSeconds > 0 ? totalSeconds - timeLeft : timeUsed)
navigate({ to: '/toeic/result' })
}, [submitExam, navigate, totalSeconds, timeLeft, timeUsed])
// Timer
useEffect(() => {
if (parts.length === 0) return
const id = setInterval(() => {
if (timeLeft > 0) {
setTimeLeft(t => { if (t <= 1) { clearInterval(id); handleSubmit(); return 0 } return t - 1 })
} else {
setTimeUsed(t => t + 1)
}
}, 1000)
return () => clearInterval(id)
}, [parts.length, timeLeft, handleSubmit])
useEffect(() => {
if (isLoading) return
if (!isAuthenticated || parts.length === 0) navigate({ to: '/toeic' })
}, [isLoading, isAuthenticated, parts.length, navigate])
if (parts.length === 0) return null
const currentPart = parts[currentPartIndex]
// Compute global question offset for current part
let globalOffset = 0
for (let i = 0; i < currentPartIndex; i++) globalOffset += parts[i].questions.length
return (
<div className="flex flex-col" style={{ height: 'calc(100vh - var(--app-header-height, 0px))' }}>
<TestSessionHeader
testName={testName}
timeLeft={timeLeft}
timeUsed={timeUsed}
onSubmit={handleSubmit}
/>
<div className="flex flex-1 overflow-hidden">
<TestSessionSidebar
parts={parts}
currentPartIndex={currentPartIndex}
answers={answers}
onSelectPart={setCurrentPart}
/>
{/* Main scrollable content */}
<main className="flex-1 overflow-y-auto bg-[#F8FAFC] px-6 py-6">
<div className="max-w-3xl mx-auto">
<h2 className="text-lg font-extrabold text-slate-700 mb-5">
Part {currentPart.partNumber}: {currentPart.partName}
</h2>
{currentPart.questions.map((q, idx) => (
<QuestionCard
key={q.id}
question={q}
globalNum={globalOffset + idx + 1}
answer={answers[q.id] ?? null}
onSelect={(i) => setAnswer(q.id, i)}
/>
))}
</div>
</main>
</div>
<TestSessionFooter
currentPartIndex={currentPartIndex}
totalParts={parts.length}
currentPartName={currentPart.partName}
onPrev={() => setCurrentPart(currentPartIndex - 1)}
onNext={() => setCurrentPart(currentPartIndex + 1)}
/>
</div>
)
}

View File

@@ -0,0 +1,36 @@
interface Props {
currentPartIndex: number
totalParts: number
currentPartName: string
onPrev: () => void
onNext: () => void
}
export function TestSessionFooter({ currentPartIndex, totalParts, currentPartName, onPrev, onNext }: Props) {
return (
<footer className="h-14 flex items-center justify-between px-5 bg-white border-t border-slate-200 flex-shrink-0 z-10">
<button
onClick={onPrev}
disabled={currentPartIndex === 0}
className="flex items-center gap-1.5 px-4 py-2 border border-slate-200 rounded-xl text-sm font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>arrow_back</span>
Part trước
</button>
<span className="text-sm font-bold text-slate-700">
Part {currentPartIndex + 1} / {totalParts}
<span className="text-slate-400 font-normal ml-1.5"> {currentPartName}</span>
</span>
<button
onClick={onNext}
disabled={currentPartIndex === totalParts - 1}
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Part tiếp theo
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>arrow_forward</span>
</button>
</footer>
)
}

View File

@@ -0,0 +1,43 @@
import { cn } from '@/lib/utils'
interface Props {
testName: string
timeLeft: number // seconds remaining; -1 = no limit (count-up mode)
timeUsed: number // seconds elapsed (used when no limit)
onSubmit: () => void
}
function formatTime(s: number): string {
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = s % 60
if (h > 0) return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
}
export function TestSessionHeader({ testName, timeLeft, timeUsed, onSubmit }: Props) {
const isUnlimited = timeLeft === -1
const displaySeconds = isUnlimited ? timeUsed : timeLeft
const isUrgent = !isUnlimited && timeLeft < 300 // last 5 min
return (
<header className="h-14 flex items-center justify-between px-5 bg-white border-b border-slate-200 shadow-sm flex-shrink-0 z-10">
<span className="font-bold text-slate-800 text-sm truncate max-w-xs">{testName}</span>
<span className={cn(
'text-2xl font-extrabold tabular-nums',
isUrgent ? 'text-red-600 timer-urgent' : 'text-blue-600',
)}>
{isUnlimited ? <span className="text-slate-400 text-base"></span> : formatTime(displaySeconds)}
</span>
<button
onClick={onSubmit}
className="flex items-center gap-1.5 px-4 py-2 bg-red-600 text-white rounded-xl text-sm font-bold hover:bg-red-700 transition-colors"
>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>send</span>
Nộp bài
</button>
</header>
)
}

View File

@@ -0,0 +1,86 @@
import { cn } from '@/lib/utils'
import type { SessionPart } from '@/types'
interface Props {
parts: SessionPart[]
currentPartIndex: number
answers: Record<number, number | null>
onSelectPart: (index: number) => void
}
export function TestSessionSidebar({ parts, currentPartIndex, answers, onSelectPart }: Props) {
// Global question offset per part for sequential numbering
let offset = 0
const partOffsets: number[] = parts.map(p => {
const o = offset
offset += p.questions.length
return o
})
return (
<aside className="w-60 flex-shrink-0 bg-white border-r border-slate-200 overflow-y-auto">
<div className="p-3 border-b border-slate-100">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Question Map</span>
</div>
{parts.map((part, partIdx) => {
const isCurrent = partIdx === currentPartIndex
return (
<div key={part.partNumber} className="px-3 pt-3 pb-1">
{/* Part label — click to switch */}
<button
onClick={() => onSelectPart(partIdx)}
className={cn(
'w-full text-left text-[10px] font-bold uppercase tracking-widest mb-2 px-1 py-0.5 rounded transition-colors',
isCurrent ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600',
)}
>
Part {part.partNumber}
</button>
{/* Question number grid */}
<div className="grid grid-cols-5 gap-1.5 mb-2">
{part.questions.map((q, qIdx) => {
const globalNum = partOffsets[partIdx] + qIdx + 1
const answered = answers[q.id] !== null && answers[q.id] !== undefined
return (
<button
key={q.id}
onClick={() => onSelectPart(partIdx)}
title={`Câu ${globalNum}`}
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center text-[11px] font-semibold transition-all',
isCurrent && answered
? 'bg-blue-600 text-white'
: !isCurrent && answered
? 'bg-blue-400 text-white'
: isCurrent
? 'border-2 border-blue-600 text-blue-600'
: 'border-2 border-slate-200 text-slate-400 hover:border-slate-300',
)}
>
{globalNum}
</button>
)
})}
</div>
</div>
)
})}
{/* Legend */}
<div className="px-4 py-3 border-t border-slate-100 mt-1">
<div className="flex flex-col gap-1.5 text-[10px] text-slate-400">
<span className="flex items-center gap-2">
<span className="w-4 h-4 rounded bg-blue-600 inline-block" />Đã trả lời
</span>
<span className="flex items-center gap-2">
<span className="w-4 h-4 rounded border-2 border-slate-200 inline-block" />Chưa làm
</span>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,14 @@
interface TimerProps {
seconds?: number
}
export function Timer({ seconds = 0 }: TimerProps) {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return (
<div className="font-mono text-lg font-semibold tabular-nums">
{String(mins).padStart(2, "0")}:{String(secs).padStart(2, "0")}
</div>
)
}

View File

@@ -0,0 +1,110 @@
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { CircularProgress } from '@/components/CircularProgress'
import { useTestStore } from '@/store/test-store'
import { TOEIC_PARTS } from '@/temp/local-data'
import { fetchQuestionsForTest } from '@/hooks/use-questions'
import { useRequireAuth } from '@/hooks/use-require-auth'
export function ToeicPractice() {
const navigate = useNavigate()
const { startExam } = useTestStore()
const [loadingPartId, setLoadingPartId] = useState<number | null>(null)
const { requireAuth } = useRequireAuth()
async function handleSelectPart(partId: number, partName: string) {
if (!requireAuth()) return
setLoadingPartId(partId)
try {
// TODO: replace hardcoded testId=1 with real test selection
const parts = await fetchQuestionsForTest(1, [partId])
startExam({ testId: 1, testName: partName, parts, totalSeconds: 0 })
navigate({ to: '/toeic/session' })
} catch (err) {
console.error('Failed to load questions:', err)
} finally {
setLoadingPartId(null)
}
}
return (
<div className="px-6 py-8 max-w-6xl mx-auto page-enter">
<div className="mb-8">
<h1 className="text-3xl font-extrabold text-slate-800 mb-2">Chọn Part TOEIC</h1>
<p className="text-slate-500">
Hệ thống ôn luyện theo cấu trúc bài thi TOEIC thực tế. Chọn phần cụ thể đ bắt đu.
</p>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5">
{TOEIC_PARTS.map((part) => (
<button
key={part.id}
onClick={() => handleSelectPart(part.id, part.nameVi)}
disabled={loadingPartId !== null}
className="bg-white rounded-2xl p-5 border border-slate-200 text-left hover:-translate-y-1 hover:shadow-md transition-all duration-200 group disabled:opacity-70 disabled:cursor-wait"
>
<div className="flex justify-between items-start mb-5">
<div className="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center group-hover:bg-blue-600 transition-colors">
{loadingPartId === part.id ? (
<span className="w-4 h-4 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin" />
) : (
<span
className="material-symbols-outlined text-blue-600 group-hover:text-white transition-colors"
style={{ fontSize: 18 }}
>
{part.icon}
</span>
)}
</div>
<CircularProgress percent={part.progressPercent} size={44} />
</div>
<div className="font-extrabold text-lg text-slate-800 mb-0.5">{part.name}</div>
<div className="text-sm font-semibold text-slate-700 mb-2">{part.nameVi}</div>
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>list_alt</span>
{part.questionCount} câu hỏi
</div>
</button>
))}
{/* Full Test card */}
<button
onClick={() => handleSelectPart(0, 'Full Test')}
className="relative rounded-2xl p-5 text-left overflow-hidden hover:-translate-y-1 hover:shadow-xl transition-all duration-200"
style={{ background: 'linear-gradient(135deg, #f59e0b, #d97706)' }}
>
<div className="absolute top-0 right-0 opacity-10">
<span className="material-symbols-outlined text-white" style={{ fontSize: 80 }}>
workspace_premium
</span>
</div>
<div className="relative z-10">
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center mb-5">
<span className="material-symbols-outlined text-white" style={{ fontSize: 18 }}>
military_tech
</span>
</div>
<div className="font-extrabold text-2xl text-white mb-0.5">Full Test</div>
<div className="text-sm font-semibold text-amber-50 mb-2"> phỏng thi thật 2h</div>
<div className="flex items-center gap-1.5 text-xs text-amber-100">
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>timer</span>
120 phút · 200 câu
</div>
</div>
</button>
</div>
{/* Tip */}
<div className="mt-8 bg-blue-50 border border-blue-100 rounded-2xl p-5 flex items-start gap-4">
<span className="material-symbols-outlined text-blue-600 flex-shrink-0 mt-0.5">tips_and_updates</span>
<div>
<div className="font-semibold text-blue-700 text-sm mb-1">Mẹo luyện thi</div>
<p className="text-slate-500 text-sm">
Bắt đu từ <strong>Part 5 (Điền từ)</strong> phần mang lại điểm nhanh nhất không phụ thuộc kỹ năng nghe. Mỗi ngày 20 câu, sau 2 tuần bạn sẽ thấy cải thiện rệt.
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,151 @@
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { cn } from '@/lib/utils'
import { fetchTestWithParts } from '@/features/toeic/api/test-list-api'
import { fetchQuestionsForTest } from '@/hooks/use-questions'
import { useTestStore } from '@/store/test-store'
import { useRequireAuth } from '@/hooks/use-require-auth'
interface Props { testId: number }
export function ToeicTestDetail({ testId }: Props) {
const navigate = useNavigate()
const { startExam } = useTestStore()
const { requireAuth } = useRequireAuth()
const [selectedParts, setSelectedParts] = useState<number[]>([])
const [loading, setLoading] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['test-detail', testId],
queryFn: () => fetchTestWithParts(testId),
})
function togglePart(partNumber: number) {
setSelectedParts(prev =>
prev.includes(partNumber) ? prev.filter(p => p !== partNumber) : [...prev, partNumber]
)
}
async function handleStart(mode: 'full' | 'parts') {
if (!requireAuth()) return
if (mode === 'parts' && selectedParts.length === 0) return
if (!data) return
setLoading(true)
try {
const partNumbers = mode === 'full' ? undefined : selectedParts
const parts = await fetchQuestionsForTest(testId, partNumbers)
const totalSeconds = mode === 'full'
? data.test.durationMinutes * 60
: selectedParts.length * 10 * 60
startExam({ testId, testName: data.test.title, parts, totalSeconds })
navigate({ to: '/toeic/session' })
} finally {
setLoading(false)
}
}
if (isLoading) {
return (
<div className="px-6 py-8 max-w-5xl mx-auto">
<div className="h-8 w-64 bg-slate-200 rounded animate-pulse mb-8" />
<div className="grid grid-cols-2 gap-5">
<div className="h-80 bg-slate-100 rounded-2xl animate-pulse" />
<div className="h-80 bg-slate-100 rounded-2xl animate-pulse" />
</div>
</div>
)
}
if (!data) return null
const { test, parts } = data
return (
<div className="px-6 py-8 max-w-5xl mx-auto page-enter">
{/* Back + title */}
<div className="flex items-center gap-3 mb-1">
<button
onClick={() => navigate({ to: '/toeic' })}
className="w-8 h-8 rounded-full border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors"
>
<span className="material-symbols-outlined text-slate-600" style={{ fontSize: 18 }}>arrow_back</span>
</button>
<h1 className="text-2xl font-extrabold text-slate-800">{test.title}</h1>
</div>
<p className="text-slate-400 text-sm ml-11 mb-8">{test.totalQuestions} câu · {test.durationMinutes} phút</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Full test card */}
<div
className="rounded-2xl p-6 flex flex-col text-white relative overflow-hidden"
style={{ background: 'linear-gradient(135deg, #2563EB, #1d4ed8)' }}
>
<div className="absolute -top-4 -right-4 opacity-10">
<span className="material-symbols-outlined" style={{ fontSize: 100 }}>military_tech</span>
</div>
<span className="material-symbols-outlined mb-4" style={{ fontSize: 32 }}>military_tech</span>
<h2 className="text-2xl font-extrabold mb-1">Thi Toàn Bộ</h2>
<p className="text-blue-100 text-sm mb-2">{test.totalQuestions} câu · {test.durationMinutes} phút · Toàn bộ {parts.length} parts</p>
<p className="text-blue-100 text-xs mb-8"> phỏng bài thi TOEIC thực tế với giới hạn thời gian.</p>
<button
onClick={() => handleStart('full')}
disabled={loading}
className="mt-auto py-3 bg-white text-blue-600 rounded-xl font-bold text-sm hover:bg-blue-50 transition-colors disabled:opacity-60 flex items-center justify-center gap-2"
>
{loading ? <span className="w-4 h-4 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin" /> : (
<><span className="material-symbols-outlined" style={{ fontSize: 18 }}>play_arrow</span>Bắt đu thi</>
)}
</button>
</div>
{/* Part selection card */}
<div className="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col">
<span className="material-symbols-outlined text-blue-600 mb-4" style={{ fontSize: 32 }}>checklist</span>
<h2 className="text-xl font-extrabold text-slate-800 mb-1">Chọn Part Luyện Tập</h2>
<p className="text-slate-400 text-sm mb-4">Chọn các part muốn luyện tập</p>
<div className="space-y-2 flex-1">
{parts.map((part) => {
const checked = selectedParts.includes(part.partNumber)
return (
<button
key={part.partNumber}
onClick={() => togglePart(part.partNumber)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-xl border-2 transition-all text-left',
checked
? 'border-blue-600 bg-blue-50'
: 'border-slate-100 hover:border-slate-200 bg-slate-50/50',
)}
>
<span className={cn(
'w-5 h-5 rounded flex items-center justify-center border-2 flex-shrink-0',
checked ? 'bg-blue-600 border-blue-600' : 'border-slate-300',
)}>
{checked && <span className="material-symbols-outlined text-white" style={{ fontSize: 14 }}>check</span>}
</span>
<span className="flex-1 text-sm font-semibold text-slate-700">
Part {part.partNumber} {part.title}
</span>
<span className="text-xs text-slate-400 bg-slate-100 px-2 py-0.5 rounded-full flex-shrink-0">
{part.questionCount} câu
</span>
</button>
)
})}
</div>
<button
onClick={() => handleStart('parts')}
disabled={loading || selectedParts.length === 0}
className="mt-4 w-full py-3 bg-blue-600 text-white rounded-xl font-bold text-sm hover:bg-blue-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loading ? <span className="w-4 h-4 border-2 border-blue-200 border-t-white rounded-full animate-spin" /> : (
<><span className="material-symbols-outlined" style={{ fontSize: 18 }}>play_arrow</span>Bắt đu luyện tập</>
)}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,112 @@
import { useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { fetchTests } from '@/features/toeic/api/test-list-api'
export function ToeicTestList() {
const navigate = useNavigate()
const { data: tests = [], isLoading, error } = useQuery({
queryKey: ['tests'],
queryFn: fetchTests,
})
return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
{/* Editorial head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div>
<div className="at-eyebrow mb-3">Luyện đ</div>
<h1 className="at-title text-4xl lg:text-[44px]">
TOEIC <i>Mock Tests</i>
</h1>
<p className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
Chọn đ đ bắt đu luyện tập {tests.length} đ thi
</p>
</div>
</div>
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="rounded-2xl h-44 animate-pulse"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
/>
))}
</div>
)}
{error && (
<div
className="rounded-2xl p-6 text-sm"
style={{ background: 'var(--at-bad-soft)', border: '1px solid rgba(193,68,62,0.2)', color: 'var(--at-bad)' }}
>
Không thể tải danh sách đ thi. Vui lòng thử lại.
</div>
)}
{!isLoading && !error && tests.length === 0 && (
<div
className="rounded-2xl p-16 text-center"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<span className="material-symbols-outlined mb-3 block" style={{ fontSize: 48, color: 'var(--at-mute-2)' }}>
library_books
</span>
<p className="at-serif text-lg" style={{ color: 'var(--at-ink)' }}>Chưa đ thi nào.</p>
<p className="text-sm mt-1" style={{ color: 'var(--at-mute)' }}>Dữ liệu đang đưc cập nhật.</p>
</div>
)}
{tests.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{tests.map((test) => (
<div
key={test.id}
className="rounded-2xl p-6 flex flex-col transition-all hover:-translate-y-1"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
{test.categoryName && (
<span className="at-chip at-chip-brand self-start mb-3">
<span className="at-chip-dot" />
{test.categoryName}
</span>
)}
<h3
className="at-serif text-[20px] leading-[1.2] tracking-tight mb-2"
style={{ color: 'var(--at-ink)', fontWeight: 500 }}
>
{test.title}
</h3>
{test.description && (
<p className="text-xs leading-[1.5] mb-3 line-clamp-2" style={{ color: 'var(--at-mute)' }}>
{test.description}
</p>
)}
<div className="flex items-center gap-4 text-xs mt-auto mb-4" style={{ color: 'var(--at-mute)' }}>
<span className="flex items-center gap-1">
<span className="material-symbols-outlined" style={{ fontSize: 13 }}>list_alt</span>
<b className="tabular-nums" style={{ color: 'var(--at-ink)' }}>{test.totalQuestions}</b> câu
</span>
<span className="flex items-center gap-1">
<span className="material-symbols-outlined" style={{ fontSize: 13 }}>timer</span>
<b className="tabular-nums" style={{ color: 'var(--at-ink)' }}>{test.durationMinutes}</b> phút
</span>
</div>
<button
onClick={() => navigate({ to: '/toeic/$testId', params: { testId: String(test.id) } })}
className="w-full py-2.5 rounded-xl text-[13px] font-semibold transition-opacity hover:opacity-90"
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)' }}
>
Bắt đu
</button>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,416 @@
import { useState, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { useWritingCheck } from '@/hooks/use-writing-check'
import { getRemainingChecks } from '@/utils/rate-limiter'
import { useRequireAuth } from '@/hooks/use-require-auth'
import { useAuthStore } from '@/store/auth-store'
import { countTodayWritingSubmissions } from '@/lib/progress-service'
import { useAwardActivity } from '@/hooks/use-gamification'
import { XP_REWARDS } from '@/lib/gamification-service'
const MAX_CHARS = 1000
const GUEST_LIMIT = 3
const AUTH_LIMIT = 10
// Extract a string field from partial JSON stream
function extractTextField(partial: string, field: string): string {
const m = partial.match(new RegExp(`"${field}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`))
if (!m) return ''
return m[1].replace(/\\n/g, '\n').replace(/\\"/g, '"')
}
// Extract score from partial JSON stream as soon as the field is complete
function extractScore(partial: string): string | null {
const m = partial.match(/"score"\s*:\s*"([^"]+)"/)
return m ? m[1] : null
}
// Extract completed array items from a partial JSON array field
function extractArrayField(partial: string, field: string): string[] {
const m = partial.match(new RegExp(`"${field}"\\s*:\\s*\\[([^\\]]*)`))
if (!m) return []
const items: string[] = []
const re = /"((?:[^"\\]|\\.)*)"/g
let match
while ((match = re.exec(m[1])) !== null) {
items.push(match[1].replace(/\\n/g, '\n').replace(/\\"/g, '"'))
}
return items
}
export function WritingChecker() {
const [text, setText] = useState('')
const [improvedExpanded, setImprovedExpanded] = useState(false)
const [remaining, setRemaining] = useState(getRemainingChecks)
const [streamingText, setStreamingText] = useState('')
const { mutate: checkWriting, isPending, isError, error, data: feedback, reset: resetMutation } = useWritingCheck()
const { requireAuth } = useRequireAuth()
const user = useAuthStore((s) => s.user)
const { mutate: awardActivity } = useAwardActivity()
const dailyLimit = user ? AUTH_LIMIT : GUEST_LIMIT
// Fetch server-side remaining count for authenticated users
useEffect(() => {
if (!user) {
setRemaining(getRemainingChecks())
resetMutation()
return
}
countTodayWritingSubmissions(user.id).then((used) => {
setRemaining(AUTH_LIMIT - used)
})
}, [user, resetMutation])
const streamingScore = isPending ? extractScore(streamingText) : null
const streamingGrammar = isPending ? extractArrayField(streamingText, 'grammar') : []
const streamingVocab = isPending ? extractArrayField(streamingText, 'vocabulary') : []
const streamingStructure = isPending ? extractTextField(streamingText, 'structure') : ''
const streamingSummary = isPending ? extractTextField(streamingText, 'summary') : ''
const charCount = text.length
const canSubmit = text.trim().length > 0 && remaining > 0 && charCount <= MAX_CHARS && !isPending
function handleSubmit() {
if (!requireAuth()) return
if (!canSubmit) return
setStreamingText('')
checkWriting(
{ content: text, onChunk: (chunk) => setStreamingText((prev) => prev + chunk) },
{
onSuccess: () => {
setStreamingText('')
if (user) {
awardActivity({ xp: XP_REWARDS.writing })
countTodayWritingSubmissions(user.id).then((used) => setRemaining(AUTH_LIMIT - used))
} else {
setRemaining(getRemainingChecks())
}
},
onError: () => {
setStreamingText('')
if (!user) setRemaining(getRemainingChecks())
},
},
)
}
const sentenceCount = text.split(/[.!?]+/).filter(s => s.trim()).length
const wordCount = text.split(/\s+/).filter(Boolean).length
return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
{/* Editorial page head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div className="min-w-0">
<div className="at-eyebrow mb-3 inline-flex items-center gap-1.5">
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>auto_awesome</span>
AI Writing Checker
</div>
<h1 className="at-title text-4xl lg:text-[44px]">
Kiểm tra <i>bài viết</i>
</h1>
<p className="mt-4 text-sm" style={{ color: 'var(--at-mute)' }}>
Dán bài viết AI sẽ kiểm tra ngữ pháp, chính tả, chấm điểm IELTS/TOEIC
</p>
</div>
<div className="flex gap-2.5 flex-shrink-0">
<button
className="inline-flex items-center gap-2 px-4 py-3 rounded-xl text-[13.5px] font-semibold transition-colors hover:opacity-80"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)', color: 'var(--at-ink-2)' }}
>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>mic</span>
Nhập bằng giọng nói
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
'inline-flex items-center gap-2 px-5 py-3 rounded-xl text-[13.5px] font-semibold transition-opacity',
canSubmit ? 'hover:opacity-90' : 'cursor-not-allowed opacity-50',
)}
style={{ background: 'var(--at-ink)', color: 'var(--at-paper)', border: '1px solid var(--at-ink)' }}
>
{isPending ? (
<>
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
Đang chấm...
</>
) : (
<>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>auto_awesome</span>
Kiểm tra ngay
</>
)}
</button>
</div>
</div>
<div className="grid lg:grid-cols-[1.5fr_1fr] gap-5">
{/* Left: Input */}
<div className="min-w-0">
<div
className="rounded-2xl overflow-hidden"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div
className="px-5 py-3.5 flex items-center justify-between"
style={{ background: 'var(--at-paper-2)', borderBottom: '1px solid var(--at-line)' }}
>
<div className="flex gap-1.5">
<span className="at-chip">
<span className="at-chip-dot" />
Đề: Working from home
</span>
<span className="at-chip at-chip-brand">
<span className="at-chip-dot" />
Essay · Band 6-7
</span>
</div>
<div className="text-xs tabular-nums" style={{ color: charCount > MAX_CHARS ? 'var(--at-bad)' : 'var(--at-mute)' }}>
{wordCount} từ · {sentenceCount} câu · {charCount}/{MAX_CHARS}
</div>
</div>
<div className="p-5">
<textarea
value={text}
onChange={(e) => setText(e.target.value.slice(0, MAX_CHARS))}
rows={12}
dir="ltr"
placeholder="Bắt đầu viết hoặc dán bài của bạn ở đây..."
className="w-full resize-none bg-transparent border-none outline-none"
style={{
fontFamily: 'var(--at-sans)',
fontSize: 15,
lineHeight: 1.7,
color: 'var(--at-ink)',
minHeight: 280,
}}
/>
<div className="mt-3 flex items-center gap-1.5">
<span className="material-symbols-outlined" style={{ fontSize: 14, color: 'var(--at-mute)' }}>info</span>
<span className="text-xs font-medium" style={{ color: remaining <= 1 ? 'var(--at-bad)' : 'var(--at-mute)' }}>
Còn {remaining}/{dailyLimit} lượt hôm nay
</span>
</div>
</div>
</div>
{remaining <= 0 && (
<div className="mt-3 bg-amber-50 border border-amber-100 rounded-xl p-4 flex items-center gap-3">
<span className="material-symbols-outlined text-amber-600 flex-shrink-0" style={{ fontSize: 20 }}>schedule</span>
<p className="text-sm text-amber-700">
Bạn đã dùng hết {dailyLimit} lượt hôm nay. Vui lòng quay lại vào ngày mai.
</p>
</div>
)}
{isError && (
<div className="mt-3 bg-red-50 border border-red-100 rounded-xl p-4 flex items-center gap-3">
<span className="material-symbols-outlined text-red-500 flex-shrink-0" style={{ fontSize: 20 }}>error</span>
<p className="text-sm text-red-600">
{(error as Error)?.message ?? 'Đã có lỗi xảy ra. Vui lòng thử lại.'}
</p>
</div>
)}
</div>
{/* Right: Feedback */}
<div className="flex flex-col gap-5">
{!feedback && !isPending && (
<div className="at-tip">
<div className="at-tip-label">AI kiểm tra ?</div>
<div className="text-[12.5px] leading-[1.55]" style={{ color: 'var(--at-ink-2)' }}>
Ngữ pháp · Chính tả · Từ vựng học thuật · Tính mạch lạc · Chấm điểm theo band IELTS/TOEIC.
Một bài TOEIC Writing band 7+ cần{' '}
<b style={{ color: 'var(--at-warm)', fontWeight: 700 }}>ít nhất 250 từ</b> sử dụng{' '}
<b style={{ color: 'var(--at-warm)', fontWeight: 700 }}>3-4 linking words</b>.
</div>
</div>
)}
{isPending && (
<div className="space-y-3">
{/* Score */}
<div className="bg-blue-600 rounded-2xl p-5 text-center">
<div className="text-xs text-blue-200 font-medium mb-1 uppercase tracking-wider">Band Score ưc tính</div>
{streamingScore ? (
<div className="text-5xl font-extrabold text-white mb-1">{streamingScore}</div>
) : (
<div className="h-12 w-20 mx-auto bg-blue-500/40 rounded-xl animate-pulse mb-1" />
)}
<div className="text-xs text-blue-200">Dựa trên tiêu chí IELTS/TOEIC Writing</div>
</div>
{/* Grammar */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-sm font-bold text-slate-800">Ngữ pháp</span>
</div>
{streamingGrammar.length > 0 ? (
<ul className="space-y-1.5">
{streamingGrammar.map((item, i) => (
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
<span className="material-symbols-outlined text-red-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>error</span>
{item}
</li>
))}
</ul>
) : (
<div className="space-y-2">
{[78, 92, 65].map((w, i) => (
<div key={i} className="flex items-start gap-2">
<div className="w-3.5 h-3.5 mt-0.5 rounded-full bg-slate-100 animate-pulse flex-shrink-0" />
<div className="h-3 bg-slate-100 rounded animate-pulse" style={{ width: `${w}%` }} />
</div>
))}
</div>
)}
</div>
{/* Vocabulary */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-2 h-2 rounded-full bg-amber-500" />
<span className="text-sm font-bold text-slate-800">Từ vựng</span>
</div>
{streamingVocab.length > 0 ? (
<ul className="space-y-1.5">
{streamingVocab.map((item, i) => (
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
<span className="material-symbols-outlined text-amber-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>lightbulb</span>
{item}
</li>
))}
</ul>
) : (
<div className="h-3 bg-slate-100 rounded animate-pulse w-4/5" />
)}
</div>
{/* Structure */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-blue-500" />
<span className="text-sm font-bold text-slate-800">Cấu trúc</span>
</div>
{streamingStructure ? (
<p className="text-xs text-slate-600">{streamingStructure}</p>
) : (
<div className="space-y-1.5">
<div className="h-3 bg-slate-100 rounded animate-pulse" />
<div className="h-3 bg-slate-100 rounded animate-pulse w-5/6" />
<div className="h-3 bg-slate-100 rounded animate-pulse w-4/6" />
</div>
)}
</div>
{/* Summary */}
<div className="bg-green-50 rounded-2xl border border-green-100 p-4">
<div className="flex items-center gap-2 mb-2">
{streamingSummary ? (
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 16 }}>summarize</span>
) : (
<div className="w-4 h-4 bg-green-200 rounded animate-pulse" />
)}
{streamingSummary ? (
<span className="text-sm font-bold text-green-700">Tổng nhận xét</span>
) : (
<div className="h-4 w-24 bg-green-200 rounded animate-pulse" />
)}
</div>
{streamingSummary ? (
<p className="text-xs text-slate-600">{streamingSummary}</p>
) : (
<div className="space-y-1.5">
<div className="h-3 bg-green-100 rounded animate-pulse" />
<div className="h-3 bg-green-100 rounded animate-pulse w-3/4" />
</div>
)}
</div>
</div>
)}
{feedback && !isPending && (
<div className="space-y-3">
{/* Band score */}
<div className="bg-blue-600 rounded-2xl p-5 text-center">
<div className="text-xs text-blue-200 font-medium mb-1 uppercase tracking-wider">Band Score ưc tính</div>
<div className="text-5xl font-extrabold text-white mb-1">{feedback.score}</div>
<div className="text-xs text-blue-200">Dựa trên tiêu chí IELTS/TOEIC Writing</div>
</div>
{/* Grammar */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-sm font-bold text-slate-800">Ngữ pháp</span>
</div>
<ul className="space-y-1.5">
{feedback.grammar.map((item, i) => (
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
<span className="material-symbols-outlined text-red-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>error</span>
{item}
</li>
))}
</ul>
</div>
{/* Vocabulary */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-2 h-2 rounded-full bg-amber-500" />
<span className="text-sm font-bold text-slate-800">Từ vựng</span>
</div>
<ul className="space-y-1.5">
{feedback.vocabulary.map((item, i) => (
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
<span className="material-symbols-outlined text-amber-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>lightbulb</span>
{item}
</li>
))}
</ul>
</div>
{/* Structure */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-blue-500" />
<span className="text-sm font-bold text-slate-800">Cấu trúc</span>
</div>
<p className="text-xs text-slate-600">{feedback.structure}</p>
</div>
{/* Improved version */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<button
onClick={() => setImprovedExpanded((v) => !v)}
className="w-full flex items-center justify-between"
>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-sm font-bold text-slate-800">Bài viết cải thiện</span>
</div>
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 18 }}>
{improvedExpanded ? 'expand_less' : 'expand_more'}
</span>
</button>
{improvedExpanded && (
<p className="mt-3 text-xs text-slate-600 leading-relaxed border-t border-slate-100 pt-3">
{feedback.improvedVersion}
</p>
)}
</div>
{/* Summary */}
<div className="bg-green-50 rounded-2xl border border-green-100 p-4">
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 16 }}>summarize</span>
<span className="text-sm font-bold text-green-700">Tổng nhận xét</span>
</div>
<p className="text-xs text-slate-600">{feedback.summary}</p>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,44 @@
interface WritingFeedbackProps {
score?: string
grammar?: string[]
vocabulary?: string[]
structure?: string
summary?: string
improvedVersion?: string
}
export function WritingFeedback({ score, grammar, vocabulary, structure, summary }: WritingFeedbackProps) {
if (!score) return null
return (
<div className="space-y-4 rounded-lg border p-4">
<div className="flex items-center gap-2">
<span className="font-semibold">Band score:</span>
<span className="text-lg font-bold text-blue-600">{score}</span>
</div>
{summary && <p className="text-sm text-gray-700">{summary}</p>}
{grammar && grammar.length > 0 && (
<div>
<p className="font-medium text-sm">Ngữ pháp:</p>
<ul className="mt-1 space-y-1 text-sm text-gray-600 list-disc list-inside">
{grammar.map((item, i) => <li key={i}>{item}</li>)}
</ul>
</div>
)}
{vocabulary && vocabulary.length > 0 && (
<div>
<p className="font-medium text-sm">Từ vựng:</p>
<ul className="mt-1 space-y-1 text-sm text-gray-600 list-disc list-inside">
{vocabulary.map((item, i) => <li key={i}>{item}</li>)}
</ul>
</div>
)}
{structure && (
<div>
<p className="font-medium text-sm">Bố cục:</p>
<p className="text-sm text-gray-600">{structure}</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,134 @@
import { useState } from 'react'
import { useWritingHistory } from '@/hooks/use-writing-history'
import { useAuthStore } from '@/store/auth-store'
import type { WritingSubmission } from '@/types'
function scoreColor(score: string) {
const n = parseFloat(score)
if (n >= 7) return 'bg-green-100 text-green-700'
if (n >= 5) return 'bg-amber-100 text-amber-700'
return 'bg-red-100 text-red-700'
}
function relativeTime(iso: string) {
const diff = Date.now() - new Date(iso).getTime()
const mins = Math.floor(diff / 60_000)
if (mins < 1) return 'vừa xong'
if (mins < 60) return `${mins} phút trước`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours} giờ trước`
return `${Math.floor(hours / 24)} ngày trước`
}
function SubmissionCard({ item }: { item: WritingSubmission }) {
const [open, setOpen] = useState(false)
const fb = item.feedback
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<button
onClick={() => setOpen((v) => !v)}
className="w-full text-left p-4 flex items-start gap-3 hover:bg-slate-50 transition-colors"
>
<span className={`text-xs font-bold px-2 py-1 rounded-lg flex-shrink-0 mt-0.5 ${scoreColor(fb?.score ?? '0')}`}>
{fb?.score ?? ''}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-slate-700 line-clamp-1">{item.content}</p>
<p className="text-xs text-slate-400 mt-0.5">{relativeTime(item.created_at)}</p>
</div>
<span className="material-symbols-outlined text-slate-400 flex-shrink-0 mt-0.5" style={{ fontSize: 18 }}>
{open ? 'expand_less' : 'expand_more'}
</span>
</button>
{open && fb && (
<div className="border-t border-slate-100 p-4 space-y-4">
<div>
<p className="text-xs text-slate-400 mb-2">Bài viết gốc</p>
<p className="text-xs text-slate-600 leading-relaxed whitespace-pre-wrap">{item.content}</p>
</div>
{fb.grammar?.length > 0 && (
<div>
<p className="text-xs font-semibold text-slate-600 mb-1.5 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-red-500 inline-block" />
Ngữ pháp
</p>
<ul className="space-y-1">
{fb.grammar.map((g, i) => (
<li key={i} className="text-xs text-slate-600 flex gap-1.5">
<span className="text-red-400 flex-shrink-0"></span>{g}
</li>
))}
</ul>
</div>
)}
{fb.vocabulary?.length > 0 && (
<div>
<p className="text-xs font-semibold text-slate-600 mb-1.5 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 inline-block" />
Từ vựng
</p>
<ul className="space-y-1">
{fb.vocabulary.map((v, i) => (
<li key={i} className="text-xs text-slate-600 flex gap-1.5">
<span className="text-amber-400 flex-shrink-0"></span>{v}
</li>
))}
</ul>
</div>
)}
{fb.structure && (
<div>
<p className="text-xs font-semibold text-slate-600 mb-1">Cấu trúc</p>
<p className="text-xs text-slate-600">{fb.structure}</p>
</div>
)}
{fb.summary && (
<div className="bg-green-50 rounded-lg p-3">
<p className="text-xs font-semibold text-green-700 mb-1">Tổng nhận xét</p>
<p className="text-xs text-slate-600">{fb.summary}</p>
</div>
)}
</div>
)}
</div>
)
}
export function WritingHistory() {
const user = useAuthStore((s) => s.user)
const { data: history, isLoading } = useWritingHistory()
if (!user) return null
return (
<section className="px-4 lg:px-6 pb-10 max-w-6xl mx-auto">
<h2 className="text-lg font-bold text-slate-800 mb-4">Lịch sử chấm bài</h2>
{isLoading && (
<div className="flex items-center gap-2 text-sm text-slate-400">
<div className="w-4 h-4 border-2 border-slate-200 border-t-blue-500 rounded-full animate-spin" />
Đang tải...
</div>
)}
{!isLoading && !history?.length && (
<div className="text-center py-10 text-slate-400">
<span className="material-symbols-outlined mb-2 block" style={{ fontSize: 36 }}>history</span>
<p className="text-sm">Chưa bài nào đưc chấm.</p>
</div>
)}
{!!history?.length && (
<div className="space-y-2">
{history.map((item) => <SubmissionCard key={item.id} item={item} />)}
</div>
)}
</section>
)
}

5
src/hooks/use-auth.ts Normal file
View File

@@ -0,0 +1,5 @@
import { useAuthStore } from '@/store/auth-store'
export const useAuth = () => useAuthStore()
export const useUser = () => useAuthStore((s) => s.user)
export const useIsAuthenticated = () => useAuthStore((s) => s.user !== null)

View File

@@ -0,0 +1,51 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '@/store/auth-store'
import {
fetchGamification,
fetchXuTransactions,
fetchLeaderboard,
awardActivity,
} from '@/lib/gamification-service'
export function useGamification() {
const user = useAuthStore((s) => s.user)
return useQuery({
queryKey: ['gamification', user?.id],
queryFn: () => fetchGamification(user!.id),
enabled: !!user,
staleTime: 30_000,
})
}
export function useXuTransactions(limit = 10) {
const user = useAuthStore((s) => s.user)
return useQuery({
queryKey: ['xu-transactions', user?.id, limit],
queryFn: () => fetchXuTransactions(user!.id, limit),
enabled: !!user,
staleTime: 30_000,
})
}
export function useLeaderboard() {
return useQuery({
queryKey: ['leaderboard'],
queryFn: fetchLeaderboard,
staleTime: 60_000,
})
}
export function useAwardActivity() {
const user = useAuthStore((s) => s.user)
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ xp }: { xp: number }) =>
awardActivity(user!.id, xp, user!.name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gamification', user?.id] })
queryClient.invalidateQueries({ queryKey: ['xu-transactions', user?.id] })
queryClient.invalidateQueries({ queryKey: ['leaderboard'] })
},
})
}

View File

@@ -0,0 +1,96 @@
import { supabase } from "@/lib/supabase"
import type { Question, SessionPart } from "@/types"
type AnswerChoiceRow = { value: string; label_text: string | null; is_correct: boolean }
type QuestionRow = { id: number; question_text: string | null; explanation: string | null; group_id: number; answer_choice: AnswerChoiceRow[] }
type GroupRow = { id: number; part_id: number; audio_url: string | null; image_url: string | null; passage_text: string | null }
type PartRow = { id: number; part_number: number }
function buildOptions(choices: AnswerChoiceRow[]): string[] {
return [...choices].sort((a, b) => a.value.localeCompare(b.value)).map(c => c.label_text ?? '')
}
function getCorrectIndex(choices: AnswerChoiceRow[]): number {
const sorted = [...choices].sort((a, b) => a.value.localeCompare(b.value))
const idx = sorted.findIndex(c => c.is_correct)
return idx >= 0 ? idx : 0
}
function rowToQuestion(row: QuestionRow, group: GroupRow, partNumber: number): Question {
return {
id: row.id,
partNumber,
text: row.question_text,
options: buildOptions(row.answer_choice),
correctAnswer: getCorrectIndex(row.answer_choice),
explanation: row.explanation,
groupId: row.group_id,
audioUrl: group.audio_url ?? undefined,
imageUrl: group.image_url ?? undefined,
passageText: group.passage_text ?? undefined,
}
}
/**
* Fetch all questions for a test, optionally filtered to specific part numbers.
* partNumbers=[] or undefined → fetch all parts of the test.
* Returns questions grouped into SessionPart[] ordered by part_number.
*/
export async function fetchQuestionsForTest(
testId: number,
partNumbers?: number[],
): Promise<SessionPart[]> {
// Step 1: Get parts for this test
let partsQuery = supabase.from('part').select('id, part_number, title').eq('test_id', testId).order('part_number')
if (partNumbers?.length) partsQuery = partsQuery.in('part_number', partNumbers)
const { data: parts, error: partsError } = await partsQuery
if (partsError) throw partsError
if (!parts?.length) return []
const partRows = parts as (PartRow & { title: string })[]
const partIds = partRows.map(p => p.id)
const partNumberById = new Map(partRows.map(p => [p.id, p.part_number]))
const partTitleByNumber = new Map(partRows.map(p => [p.part_number, p.title]))
// Step 2: Get question_groups for those parts
const { data: groups, error: groupsError } = await supabase
.from('question_group')
.select('id, part_id, audio_url, image_url, passage_text')
.in('part_id', partIds)
if (groupsError) throw groupsError
if (!groups?.length) return []
const groupMap = new Map<number, GroupRow>((groups as GroupRow[]).map(g => [g.id, g]))
const groupIds = (groups as GroupRow[]).map(g => g.id)
// Step 3: Get questions with answer choices
const { data: rows, error } = await supabase
.from('question')
.select('id, question_text, explanation, group_id, answer_choice(value, label_text, is_correct)')
.in('group_id', groupIds)
.order('question_number')
if (error) throw error
const questions = (rows as QuestionRow[] ?? [])
.map(row => {
const group = groupMap.get(row.group_id)!
const partNumber = partNumberById.get(group.part_id)!
return rowToQuestion(row, group, partNumber)
})
.filter(q => q.options.length > 0)
// Group into SessionPart[] ordered by partNumber
const byPart = new Map<number, Question[]>()
for (const q of questions) {
if (!byPart.has(q.partNumber)) byPart.set(q.partNumber, [])
byPart.get(q.partNumber)!.push(q)
}
return partRows
.filter(p => byPart.has(p.part_number))
.map(p => ({
partNumber: p.part_number,
partName: partTitleByNumber.get(p.part_number) ?? `Part ${p.part_number}`,
questions: byPart.get(p.part_number)!,
}))
}

View File

@@ -0,0 +1,17 @@
import { useAuthStore } from '@/store/auth-store'
import { useAuthModalStore } from '@/store/auth-modal-store'
export function useRequireAuth() {
const user = useAuthStore((s) => s.user)
const isLoading = useAuthStore((s) => s.isLoading)
const openModal = useAuthModalStore((s) => s.open)
/** Returns true if authenticated. If guest, opens auth modal and returns false. */
function requireAuth(): boolean {
if (user) return true
openModal('register')
return false
}
return { isAuthenticated: !!user, isLoading, requireAuth }
}

32
src/hooks/use-vocab.ts Normal file
View File

@@ -0,0 +1,32 @@
import { useQuery } from "@tanstack/react-query"
import { supabase } from "@/lib/supabase"
import type { VocabWord, VocabTopic } from "@/types"
// Maps a Supabase row to VocabWord.
// DB column `meaning_vi` → interface field `meaningVi`.
function rowToVocabWord(row: Record<string, unknown>): VocabWord {
return {
id: row.id as string,
word: row.word as string,
phonetic: (row.phonetic as string) ?? '',
meaningVi: row.meaning_vi as string,
topic: row.topic as VocabTopic,
example: (row.example as string) ?? '',
}
}
// Fetches ALL vocab; topic filtering is done in-component so we avoid
// separate queries per topic and keep the cache simple.
export function useVocab() {
return useQuery({
queryKey: ['vocab'],
queryFn: async () => {
const { data, error } = await supabase
.from('vocab')
.select('*')
.order('topic')
if (error) throw error
return (data ?? []).map(rowToVocabWord)
},
})
}

View File

@@ -0,0 +1,140 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { canUseWritingCheck, recordWritingCheckUsage } from "@/utils/rate-limiter"
import { useAuthStore } from "@/store/auth-store"
import { saveWritingSubmission, countTodayWritingSubmissions } from "@/lib/progress-service"
import type { WritingFeedback } from "@/types"
const AUTH_DAILY_LIMIT = 10
const GUEST_DAILY_LIMIT = 3
// Resolve env at runtime — production injects window.__ENV__ via docker/entrypoint.sh,
// dev reads from Vite's import.meta.env. Must match src/lib/supabase.ts.
function resolveSupabaseEnv() {
const runtime = (window as unknown as { __ENV__?: Record<string, string> }).__ENV__ ?? {}
const url = runtime.VITE_SUPABASE_URL || import.meta.env.VITE_SUPABASE_URL
const key =
runtime.VITE_SUPABASE_ANON_KEY ||
runtime.VITE_SUPABASE_PUBLISHABLE_KEY ||
import.meta.env.VITE_SUPABASE_ANON_KEY ||
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
return { url: url as string | undefined, key: key as string | undefined }
}
// Calls the writing-check-dbiz Supabase edge function.
// SSE format emitted by the function: data: {"text":"..."} | data: [DONE]
async function callEdgeFunction(
content: string,
onChunk?: (text: string) => void,
): Promise<WritingFeedback> {
const { url, key } = resolveSupabaseEnv()
if (!url || !key) {
throw new Error("Supabase chưa được cấu hình. Vui lòng kiểm tra biến môi trường.")
}
const res = await fetch(`${url}/functions/v1/writing-check-dbiz`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${key}`,
apikey: key,
},
body: JSON.stringify({ content }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body?.error ?? "Đã có lỗi khi chấm bài. Vui lòng thử lại.")
}
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buffer = ""
let accumulated = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.startsWith("data: ")) continue
const payload = line.slice(6).trim()
if (payload === "[DONE]") continue
let chunk: { text?: string; error?: string }
try {
chunk = JSON.parse(payload)
} catch {
continue
}
if (chunk.error) throw new Error(chunk.error)
const text = chunk.text ?? ""
if (text) {
accumulated += text
onChunk?.(text)
}
}
}
const start = accumulated.indexOf("{")
const end = accumulated.lastIndexOf("}")
if (start === -1 || end === -1) {
throw new Error("Phản hồi từ AI không hợp lệ. Vui lòng thử lại.")
}
const raw = JSON.parse(accumulated.slice(start, end + 1))
const toArray = (v: unknown): string[] => {
if (Array.isArray(v)) return v
if (typeof v === "string" && v.length > 0) return [v]
return []
}
return {
...raw,
grammar: toArray(raw.grammar),
vocabulary: toArray(raw.vocabulary),
improvedVersion: raw.improved_version ?? raw.improvedVersion ?? "",
} as WritingFeedback
}
export function useWritingCheck() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
content,
onChunk,
}: {
content: string
onChunk?: (text: string) => void
}): Promise<WritingFeedback> => {
const user = useAuthStore.getState().user
if (user) {
const usedToday = await countTodayWritingSubmissions(user.id)
if (usedToday >= AUTH_DAILY_LIMIT) {
throw new Error(`Bạn đã dùng hết ${AUTH_DAILY_LIMIT} lần kiểm tra hôm nay. Quay lại vào ngày mai!`)
}
} else {
if (!canUseWritingCheck()) {
throw new Error(`Bạn đã dùng hết ${GUEST_DAILY_LIMIT} lần kiểm tra hôm nay. Đăng ký để được 10 lần/ngày!`)
}
}
const feedback = await callEdgeFunction(content, onChunk)
if (user) {
await saveWritingSubmission(user.id, content, feedback)
queryClient.invalidateQueries({ queryKey: ["writing-history"] })
} else {
recordWritingCheckUsage()
}
return feedback
},
})
}

View File

@@ -0,0 +1,14 @@
import { useQuery } from "@tanstack/react-query"
import { useAuthStore } from "@/store/auth-store"
import { fetchWritingHistory } from "@/lib/progress-service"
import type { WritingSubmission } from "@/types"
export function useWritingHistory() {
const user = useAuthStore((s) => s.user)
return useQuery<WritingSubmission[]>({
queryKey: ["writing-history", user?.id],
queryFn: () => fetchWritingHistory(user!.id) as Promise<WritingSubmission[]>,
enabled: !!user,
staleTime: 30_000,
})
}

504
src/index.css Normal file
View File

@@ -0,0 +1,504 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Plus Jakarta Sans', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
/* Atelier palette (global — applied across entire app) */
--at-brand: #3D4BD7;
--at-brand-ink: #1A2280;
--at-brand-soft: #E9ECFE;
--at-brand-softer: #F4F5FE;
--at-ink: #0F1114;
--at-ink-2: #2A2D33;
--at-ink-3: #3E4149;
--at-mute: #6B6F76;
--at-mute-2: #9CA0A8;
--at-line: #E8E5DE;
--at-line-2: #EFECE4;
--at-paper: #FAF8F3;
--at-paper-2: #F4F1EA;
--at-surface: #FFFFFF;
--at-good: #2F7D4A;
--at-good-soft: #E4F0E7;
--at-good-ink: #1B4B2C;
--at-warm: #D26A3B;
--at-warm-soft: #F8E9DE;
--at-warm-ink: #6B2A14;
--at-bad: #C1443E;
--at-bad-soft: #F4DEDC;
--at-streak: #C15A34;
--at-streak-soft: #F7E6DC;
--at-serif: "Fraunces", "Instrument Serif", Georgia, serif;
--at-sans: "Geist", "Geist Variable", "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--at-mono: "Geist Mono", ui-monospace, SF Mono, Menlo, monospace;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(54.6% 0.245 262.3); /* #2563EB */
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
background: var(--at-paper);
color: var(--at-ink);
font-family: var(--at-sans);
font-feature-settings: "ss01", "cv11";
-webkit-font-smoothing: antialiased;
letter-spacing: -0.005em;
}
html {
@apply font-sans;
}
}
/* Atelier global helpers — usable outside .atelier scope */
.at-serif { font-family: var(--at-serif); }
.at-mono { font-family: var(--at-mono); }
.at-eyebrow {
font-family: var(--at-serif);
font-style: italic;
font-weight: 500;
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--at-mute);
}
.at-title {
font-family: var(--at-serif);
font-weight: 400;
letter-spacing: -0.025em;
line-height: 1.05;
color: var(--at-ink);
}
.at-title i { font-style: italic; color: var(--at-brand); font-weight: 400; }
.at-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 999px;
background: var(--at-line-2);
color: var(--at-ink-3);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
}
.at-chip-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.at-chip-brand { background: var(--at-brand-soft); color: var(--at-brand-ink); }
.at-chip-good { background: var(--at-good-soft); color: var(--at-good-ink); }
.at-chip-warm { background: var(--at-warm-soft); color: var(--at-warm); }
.at-bar {
height: 6px;
background: var(--at-line-2);
border-radius: 999px;
overflow: hidden;
position: relative;
}
.at-bar > span {
position: absolute;
inset: 0 auto 0 0;
background: var(--at-brand);
border-radius: 999px;
transition: width 0.5s cubic-bezier(0.2, 0.7, 0.2, 1);
}
.at-pullquote {
padding: 18px;
border-radius: 16px;
background: var(--at-brand-soft);
position: relative;
overflow: hidden;
}
.at-pullquote-q {
font-family: var(--at-serif);
font-size: 15px;
font-style: italic;
line-height: 1.45;
color: var(--at-brand-ink);
letter-spacing: -0.01em;
}
.at-tip {
padding: 16px;
background: var(--at-warm-soft);
border-radius: 14px;
border: 1px solid rgba(210, 106, 59, 0.18);
}
.at-tip-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--at-warm);
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.at-tip-label::before {
content: "";
width: 5px; height: 5px; border-radius: 50%;
background: var(--at-warm);
}
/* ── Flashcard 3D flip ── */
.flashcard-scene {
perspective: 1000px;
}
.flashcard-inner {
position: relative;
transform-style: preserve-3d;
transition: transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
}
.flashcard-inner.is-flipped {
transform: rotateY(180deg);
}
.flashcard-face {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
border-radius: 20px;
}
.flashcard-back {
transform: rotateY(180deg);
}
/* ── Material Symbols ── */
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
vertical-align: middle;
}
/* ── Page fade-in ── */
@keyframes page-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.page-enter {
animation: page-in 0.2s ease both;
}
/* ── Timer urgent pulse ── */
@keyframes timer-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.timer-urgent {
animation: timer-pulse 1s ease-in-out infinite;
}
/* ────────────────────────────────────────────────────────────
The Atelier — flashcard learn page scope
Tokens + typography + 3D flip card
Fonts: Fraunces + Geist + Geist Mono (loaded by route)
──────────────────────────────────────────────────────────── */
.atelier {
--at-accent: #3D4BD7;
--at-accent-soft: #E9ECFE;
--at-accent-ink: #1A2280;
--at-ink: #0F1114;
--at-ink-2: #2A2D33;
--at-mute: #6B6F76;
--at-mute-2: #9CA0A8;
--at-line: #E8E5DE;
--at-line-2: #EFECE4;
--at-paper: #FAF8F3;
--at-paper-2: #F4F1EA;
--at-good: #2F7D4A;
--at-good-soft: #E4F0E7;
--at-warm: #D26A3B;
--at-warm-soft: #F8E9DE;
--at-serif: "Fraunces", "Instrument Serif", Georgia, serif;
--at-sans: "Geist", "Geist Variable", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--at-mono: "Geist Mono", ui-monospace, SF Mono, Menlo, monospace;
background: var(--at-paper);
color: var(--at-ink);
font-family: var(--at-sans);
font-feature-settings: "ss01", "cv11";
-webkit-font-smoothing: antialiased;
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
letter-spacing: -0.005em;
}
.atelier .at-serif { font-family: var(--at-serif); }
.atelier .at-mono { font-family: var(--at-mono); }
/* Card */
.atelier .at-card-outer {
perspective: 2000px;
width: 100%;
max-width: 420px;
margin: 0 auto;
/* Size by available viewport height — never overflow */
height: min(560px, calc(100vh - 14rem));
max-height: 560px;
}
.atelier .at-card {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.75s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
animation: at-cardIn 0.5s cubic-bezier(0.2, 0.7, 0.2, 1);
}
@keyframes at-cardIn {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.atelier .at-card.is-flipped { transform: rotateY(180deg); }
.atelier .at-card-face {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
background: #fff;
border-radius: 28px;
padding: 28px 32px;
display: flex;
flex-direction: column;
border: 1px solid var(--at-line);
box-shadow:
0 1px 2px rgba(15,17,20,0.04),
0 20px 40px -16px rgba(15,17,20,0.12),
0 4px 12px -4px rgba(15,17,20,0.06);
}
.atelier .at-card-back { transform: rotateY(180deg); }
.atelier .at-word {
font-family: var(--at-serif);
font-size: clamp(44px, 6vw, 72px);
font-weight: 400;
line-height: 1;
letter-spacing: -0.035em;
color: var(--at-ink);
font-variation-settings: "opsz" 144, "SOFT" 30, "WONK" 1;
}
.atelier .at-meaning {
font-family: var(--at-serif);
font-size: 26px;
font-weight: 400;
line-height: 1.15;
letter-spacing: -0.02em;
color: var(--at-ink);
}
.atelier .at-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--at-accent-soft);
color: var(--at-accent-ink);
border-radius: 999px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.14em;
}
.atelier .at-chip-dot {
width: 5px; height: 5px; border-radius: 50%;
background: var(--at-accent);
}
.atelier .at-chip-mute { background: var(--at-line-2); color: var(--at-mute); }
.atelier .at-chip-mute .at-chip-dot { background: var(--at-mute); }
.atelier .at-kbd {
font-family: var(--at-mono);
font-size: 10.5px;
padding: 2px 7px;
border: 1px solid var(--at-line);
border-bottom-width: 2px;
border-radius: 5px;
color: var(--at-ink-2);
background: var(--at-paper-2);
}
.atelier .at-example {
padding: 14px 16px;
background: var(--at-paper-2);
border-radius: 12px;
border-left: 2px solid var(--at-accent);
}
.atelier .at-action {
flex: 1;
padding: 13px 18px;
border-radius: 12px;
font-weight: 600;
font-size: 13.5px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid var(--at-line);
background: #fff;
color: var(--at-ink-2);
transition: all 0.15s ease;
}
.atelier .at-action:hover:not(:disabled) { border-color: var(--at-ink); color: var(--at-ink); transform: translateY(-1px); }
.atelier .at-action:disabled { opacity: 0.4; cursor: not-allowed; }
.atelier .at-action-known {
background: var(--at-good);
color: white;
border-color: var(--at-good);
}
.atelier .at-action-known:hover:not(:disabled) { background: #236238; border-color: #236238; color: white; }
.atelier .at-action-review {
background: var(--at-warm-soft);
color: var(--at-warm);
border-color: rgba(210, 106, 59, 0.3);
}
.atelier .at-action-review:hover:not(:disabled) { background: var(--at-warm); color: white; border-color: var(--at-warm); }
.atelier .at-progress-bar {
height: 6px;
background: var(--at-line-2);
border-radius: 999px;
overflow: hidden;
position: relative;
}
.atelier .at-progress-bar > span {
position: absolute;
inset: 0 auto 0 0;
background: linear-gradient(90deg, var(--at-accent), color-mix(in oklab, var(--at-accent) 80%, white));
border-radius: 999px;
transition: width 0.5s cubic-bezier(0.2, 0.7, 0.2, 1);
}
.atelier .at-pct {
font-family: var(--at-serif);
font-size: 22px;
color: var(--at-accent);
font-weight: 400;
font-style: italic;
letter-spacing: -0.02em;
}
/* Swipe-off FX */
@keyframes at-knownFx {
0% { transform: translateY(0); }
40% { transform: translateY(-8px) rotate(2deg); }
100% { transform: translateX(120%) rotate(8deg); opacity: 0; }
}
@keyframes at-reviewFx {
0% { transform: translateY(0); }
40% { transform: translateY(-8px) rotate(-2deg); }
100% { transform: translateX(-120%) rotate(-8deg); opacity: 0; }
}
.atelier .at-card.fx-known { animation: at-knownFx 0.55s cubic-bezier(0.4,0,0.2,1) forwards; }
.atelier .at-card.fx-review { animation: at-reviewFx 0.55s cubic-bezier(0.4,0,0.2,1) forwards; }

View File

@@ -0,0 +1,209 @@
import { supabase } from '@/lib/supabase'
import type { UserGamification, XuTransaction, UserLevel } from '@/types'
// XP awarded per action type
export const XP_REWARDS = { test: 20, writing: 15 } as const
// Xu awarded at streak milestones
const STREAK_XU: Record<number, number> = { 7: 20, 30: 50, 100: 100 }
function calcLevel(xp: number): UserLevel {
if (xp >= 10000) return 'master'
if (xp >= 5000) return 'gold'
if (xp >= 2000) return 'silver'
if (xp >= 500) return 'bronze'
return 'beginner'
}
function today(): string {
return new Date().toISOString().split('T')[0]
}
function yesterday(): string {
const d = new Date()
d.setDate(d.getDate() - 1)
return d.toISOString().split('T')[0]
}
export function getWeekStart(): string {
const d = new Date()
const day = d.getDay()
const diff = day === 0 ? -6 : 1 - day
d.setDate(d.getDate() + diff)
return d.toISOString().split('T')[0]
}
// Map Supabase snake_case row → camelCase type
function mapGamification(row: Record<string, unknown>): UserGamification {
return {
userId: row.user_id as string,
xp: row.xp as number,
level: row.level as UserLevel,
streak: row.streak as number,
longestStreak: row.longest_streak as number,
lastActive: (row.last_active as string) ?? null,
xu: row.xu as number,
freezeCount: row.freeze_count as number,
createdAt: row.created_at as string,
}
}
// ─── Fetch ────────────────────────────────────────────────────────────────────
export async function fetchGamification(userId: string): Promise<UserGamification | null> {
const { data, error } = await supabase
.from('user_gamification')
.select('*')
.eq('user_id', userId)
.single()
if (error) {
if (error.code === 'PGRST116') return null
throw error
}
return mapGamification(data as Record<string, unknown>)
}
export async function fetchXuTransactions(userId: string, limit = 10): Promise<XuTransaction[]> {
const { data, error } = await supabase
.from('xu_transactions')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(limit)
if (error) throw error
return (data ?? []).map((row: Record<string, unknown>) => ({
id: row.id as string,
userId: row.user_id as string,
type: row.type as XuTransaction['type'],
amount: row.amount as number,
balance: row.balance as number,
description: (row.description as string) ?? null,
createdAt: row.created_at as string,
}))
}
export interface LeaderboardRow {
userId: string
displayName: string
xpEarned: number
rank: number
}
export async function fetchLeaderboard(): Promise<LeaderboardRow[]> {
const weekStart = getWeekStart()
// Step 1: fetch leaderboard rankings
const { data: lbRows, error: lbErr } = await supabase
.from('weekly_leaderboard')
.select('user_id, xp_earned')
.eq('week_start', weekStart)
.order('xp_earned', { ascending: false })
.limit(20)
if (lbErr) throw lbErr
if (!lbRows?.length) return []
// Step 2: fetch display names from user_gamification
const userIds = lbRows.map((r) => r.user_id)
const { data: gamRows } = await supabase
.from('user_gamification')
.select('user_id, display_name')
.in('user_id', userIds)
const nameMap = new Map((gamRows ?? []).map((r) => [r.user_id, r.display_name ?? 'Người dùng']))
return lbRows.map((row, i) => ({
userId: row.user_id,
displayName: nameMap.get(row.user_id) ?? 'Người dùng',
xpEarned: row.xp_earned,
rank: i + 1,
}))
}
// ─── Award Activity ───────────────────────────────────────────────────────────
export interface AwardResult {
xpGained: number
xuGained: number
newStreak: number
levelUp: boolean
}
export async function awardActivity(
userId: string,
xpAmount: number,
displayName: string,
): Promise<AwardResult> {
const todayStr = today()
const current = await fetchGamification(userId)
const prevXp = current?.xp ?? 0
const prevXu = current?.xu ?? 50
const prevStreak = current?.streak ?? 0
const lastActive = current?.lastActive ?? null
// Streak logic
const isNewDay = lastActive !== todayStr
const isConsecutive = lastActive === yesterday()
let newStreak: number
if (!lastActive) newStreak = 1
else if (!isNewDay) newStreak = prevStreak
else if (isConsecutive) newStreak = prevStreak + 1
else newStreak = 1 // streak broken
// XP + level
const newXp = prevXp + xpAmount
const newLevel = calcLevel(newXp)
const levelUp = newLevel !== calcLevel(prevXp)
// Xu (daily goal + streak milestones, once per day)
let xuGained = 0
const txRows: Array<{ user_id: string; type: string; amount: number; balance: number; description: string }> = []
if (isNewDay) {
xuGained += 10
txRows.push({ user_id: userId, type: 'earn_daily', amount: 10, balance: 0, description: 'Hoàn thành mục tiêu ngày' })
}
if (isNewDay && STREAK_XU[newStreak]) {
const bonus = STREAK_XU[newStreak]
xuGained += bonus
txRows.push({ user_id: userId, type: 'earn_streak', amount: bonus, balance: 0, description: `Streak ${newStreak} ngày` })
}
const newXu = prevXu + xuGained
// Back-fill running balance in transaction rows
txRows.forEach((t, i) => { t.balance = prevXu + txRows.slice(0, i + 1).reduce((s, r) => s + r.amount, 0) })
// Upsert gamification state
await supabase.from('user_gamification').upsert({
user_id: userId,
xp: newXp,
level: newLevel,
streak: newStreak,
longest_streak: Math.max(current?.longestStreak ?? 0, newStreak),
last_active: todayStr,
xu: newXu,
freeze_count: current?.freezeCount ?? 0,
display_name: displayName,
}, { onConflict: 'user_id' })
if (txRows.length > 0) {
await supabase.from('xu_transactions').insert(txRows)
}
// Accumulate XP on weekly leaderboard
const weekStart = getWeekStart()
const { data: existing } = await supabase
.from('weekly_leaderboard')
.select('xp_earned')
.eq('user_id', userId)
.eq('week_start', weekStart)
.single()
await supabase.from('weekly_leaderboard').upsert({
user_id: userId,
week_start: weekStart,
xp_earned: (existing?.xp_earned ?? 0) + xpAmount,
}, { onConflict: 'user_id,week_start' })
return { xpGained: xpAmount, xuGained, newStreak, levelUp }
}

View File

@@ -0,0 +1,95 @@
import { supabase } from '@/lib/supabase'
const ANSWER_VALUES = ['A', 'B', 'C', 'D'] as const
interface TestResultData {
testId: number | null
selectedParts: number[]
score: number
total: number
timeUsed: number
answers: { questionId: number; selected: number | null; correct: boolean }[]
}
/** Fire-and-forget: save test result. Failures are logged but don't block UI. */
export async function saveTestResult(userId: string, data: TestResultData): Promise<void> {
const { data: attempt, error: attemptError } = await supabase
.from('user_test_attempt')
.insert({
user_id: userId,
test_id: data.testId,
selected_parts: data.selectedParts,
time_limit_minutes: 10,
submitted_at: new Date().toISOString(),
time_spent_seconds: data.timeUsed,
total_correct: data.score,
total_questions: data.total,
})
.select('id')
.single()
if (attemptError) {
console.error('Failed to save test attempt:', attemptError.message)
return
}
const answerRows = data.answers.map(a => ({
attempt_id: attempt.id,
question_id: a.questionId,
selected_value: a.selected !== null ? ANSWER_VALUES[a.selected] : null,
is_correct: a.correct,
}))
const { error: answersError } = await supabase.from('user_answer').insert(answerRows)
if (answersError) console.error('Failed to save answers:', answersError.message)
}
/** Fire-and-forget: save writing submission with AI feedback. */
export async function saveWritingSubmission(
userId: string,
content: string,
feedback: object,
): Promise<void> {
const { error } = await supabase.from('writing_submissions').insert({
user_id: userId,
content,
feedback,
})
if (error) console.error('Failed to save writing submission:', error.message)
}
/** Count today's writing submissions for server-side rate limiting (authenticated users). */
export async function countTodayWritingSubmissions(userId: string): Promise<number> {
const today = new Date().toISOString().split('T')[0]
const { count, error } = await supabase
.from('writing_submissions')
.select('*', { count: 'exact', head: true })
.eq('user_id', userId)
.gte('created_at', `${today}T00:00:00.000Z`)
if (error) return 0
return count ?? 0
}
/** Fetch test history for a user (most recent first, max 20). */
export async function fetchTestHistory(userId: string) {
const { data, error } = await supabase
.from('user_test_attempt')
.select('id, selected_parts, time_spent_seconds, total_correct, total_questions, score, submitted_at, created_at')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(20)
if (error) throw error
return data ?? []
}
/** Fetch writing history for a user (most recent first, max 20). */
export async function fetchWritingHistory(userId: string) {
const { data, error } = await supabase
.from('writing_submissions')
.select('id, content, feedback, created_at')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(20)
if (error) throw error
return data ?? []
}

10
src/lib/query-client.ts Normal file
View File

@@ -0,0 +1,10 @@
import { QueryClient } from "@tanstack/react-query"
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
})

25
src/lib/supabase.ts Normal file
View File

@@ -0,0 +1,25 @@
import { createClient } from "@supabase/supabase-js"
// Runtime env (injected by docker/entrypoint.sh) takes priority over build-time vars
const runtimeEnv = (window as unknown as { __ENV__?: Record<string, string> }).__ENV__ ?? {}
const supabaseUrl =
runtimeEnv.VITE_SUPABASE_URL ||
import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey =
runtimeEnv.VITE_SUPABASE_ANON_KEY ||
runtimeEnv.VITE_SUPABASE_PUBLISHABLE_KEY ||
import.meta.env.VITE_SUPABASE_ANON_KEY ||
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
if (!supabaseUrl || !supabaseAnonKey) {
console.warn(
"Supabase env vars missing. Set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY (or VITE_SUPABASE_PUBLISHABLE_KEY) in .env",
)
}
export const supabase = createClient(
supabaseUrl || "https://placeholder.supabase.co",
supabaseAnonKey || "placeholder-key",
)

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

23
src/main.tsx Normal file
View File

@@ -0,0 +1,23 @@
import React from "react"
import ReactDOM from "react-dom/client"
import { RouterProvider, createRouter } from "@tanstack/react-router"
import { QueryClientProvider } from "@tanstack/react-query"
import { routeTree } from "./routeTree.gen"
import { queryClient } from "./lib/query-client"
import "./index.css"
const router = createRouter({ routeTree })
declare module "@tanstack/react-router" {
interface Register {
router: typeof router
}
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>,
)

31
src/routes/__root.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { useEffect } from 'react'
import { Sidebar } from '@/components/layout/Sidebar'
import { AppHeader } from '@/components/layout/AppHeader'
import { MobileNav } from '@/components/layout/MobileNav'
import { AuthModal } from '@/features/auth/components/AuthModal'
import { useAuthStore } from '@/store/auth-store'
export const Route = createRootRoute({
component: RootLayout,
})
function RootLayout() {
const initialize = useAuthStore((s) => s.initialize)
useEffect(() => {
initialize()
}, [initialize])
return (
<div className="min-h-screen bg-slate-50">
<Sidebar />
<AppHeader />
<main className="lg:ml-60 pt-16 pb-20 lg:pb-0 min-h-screen">
<Outlet />
</main>
<MobileNav />
<AuthModal />
</div>
)
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { Dashboard } from '@/features/dashboard/components/Dashboard'
export const Route = createFileRoute('/archivement')({
component: Dashboard,
})

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { LoginPage } from '@/features/auth/components/LoginPage'
export const Route = createFileRoute('/auth/login')({
component: LoginPage,
})

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { RegisterPage } from '@/features/auth/components/RegisterPage'
export const Route = createFileRoute('/auth/register')({
component: RegisterPage,
})

View File

@@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router"
import { FlashCardTermsPage } from "@/features/flash-card/components/FlashCardTermsPage"
export const Route = createFileRoute("/flash-card/$listId/")({
component: TermsPage,
})
function TermsPage() {
const { listId } = Route.useParams()
return <FlashCardTermsPage listId={Number(listId)} />
}

View File

@@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router"
import { FlashCardLearnPage } from "@/features/flash-card/components/FlashCardLearnPage"
export const Route = createFileRoute("/flash-card/$listId/learn")({
component: LearnPage,
})
function LearnPage() {
const { listId } = Route.useParams()
return <FlashCardLearnPage listId={Number(listId)} />
}

View File

@@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from "@tanstack/react-router"
export const Route = createFileRoute("/flash-card/$listId")({
component: () => <Outlet />,
})

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
import { FlashCardListPage } from "@/features/flash-card/components/FlashCardListPage"
export const Route = createFileRoute("/flash-card/")({
component: FlashCardListPage,
})

View File

@@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from "@tanstack/react-router"
export const Route = createFileRoute("/flash-card")({
component: () => <Outlet />,
})

6
src/routes/index.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
import { Home } from "@/features/home/components/Home"
export const Route = createFileRoute("/")({
component: Home,
})

6
src/routes/settings.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { Settings } from '@/features/settings/components/Settings'
export const Route = createFileRoute('/settings')({
component: Settings,
})

View File

@@ -0,0 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'
import { ToeicTestDetail } from '@/features/toeic/components/ToeicTestDetail'
export const Route = createFileRoute('/toeic/$testId')({
component: TestDetailPage,
})
function TestDetailPage() {
const { testId } = Route.useParams()
return <ToeicTestDetail testId={Number(testId)} />
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
import { ToeicTestList } from "@/features/toeic/components/ToeicTestList"
export const Route = createFileRoute("/toeic/")({
component: ToeicTestList,
})

View File

@@ -0,0 +1,15 @@
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/toeic/part/$partId")({
component: ToeicPartConfig,
})
function ToeicPartConfig() {
const { partId } = Route.useParams()
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">TOEIC Part {partId}</h1>
<p className="text-gray-500">Cấu hình bài thi placeholder</p>
</div>
)
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
import { TestResult } from "@/features/toeic/components/TestResult"
export const Route = createFileRoute("/toeic/result")({
component: TestResult,
})

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
import { TestSession } from "@/features/toeic/components/TestSession"
export const Route = createFileRoute("/toeic/session")({
component: TestSession,
})

9
src/routes/toeic.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { createFileRoute, Outlet } from "@tanstack/react-router"
export const Route = createFileRoute("/toeic")({
component: ToeicLayout,
})
function ToeicLayout() {
return <Outlet />
}

16
src/routes/writing.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { createFileRoute } from "@tanstack/react-router"
import { WritingChecker } from "@/features/writing/components/WritingChecker"
import { WritingHistory } from "@/features/writing/components/WritingHistory"
function WritingPage() {
return (
<>
<WritingChecker />
<WritingHistory />
</>
)
}
export const Route = createFileRoute("/writing")({
component: WritingPage,
})

View File

@@ -0,0 +1,15 @@
import { create } from 'zustand'
interface AuthModalState {
isOpen: boolean
mode: 'login' | 'register'
open: (mode?: 'login' | 'register') => void
close: () => void
}
export const useAuthModalStore = create<AuthModalState>((set) => ({
isOpen: false,
mode: 'register',
open: (mode = 'register') => set({ isOpen: true, mode }),
close: () => set({ isOpen: false }),
}))

71
src/store/auth-store.ts Normal file
View File

@@ -0,0 +1,71 @@
import { create } from 'zustand'
import { supabase } from '@/lib/supabase'
import type { User } from '@/types'
interface AuthState {
user: User | null
isLoading: boolean
login: (email: string, password: string) => Promise<void>
register: (name: string, email: string, password: string) => Promise<void>
logout: () => Promise<void>
initialize: () => Promise<void>
}
function sessionToUser(session: { user: { id: string; email?: string; user_metadata?: { name?: string } } } | null): User | null {
if (!session) return null
const { id, email, user_metadata } = session.user
const name = user_metadata?.name ?? email?.split('@')[0] ?? 'Người dùng'
return { id, email: email ?? '', name }
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isLoading: true,
initialize: async () => {
set({ isLoading: true })
// Restore existing session (JWT in localStorage via Supabase SDK)
const { data: { session } } = await supabase.auth.getSession()
set({ user: sessionToUser(session), isLoading: false })
// Keep state in sync across tabs and token refresh
supabase.auth.onAuthStateChange((_event, newSession) => {
set({ user: sessionToUser(newSession), isLoading: false })
})
},
login: async (email, password) => {
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) throw new Error(mapAuthError(error.message))
// onAuthStateChange fires and updates user state
},
register: async (name, email, password) => {
const { error } = await supabase.auth.signUp({
email,
password,
options: { data: { name } },
})
if (error) throw new Error(mapAuthError(error.message))
// onAuthStateChange fires and updates user state
},
logout: async () => {
await supabase.auth.signOut()
// onAuthStateChange fires → user set to null
},
}))
function mapAuthError(msg: string): string {
if (msg.includes('already registered') || msg.includes('already exists')) {
return 'Email này đã được sử dụng. Vui lòng đăng nhập.'
}
if (msg.includes('Invalid login credentials') || msg.includes('invalid_credentials')) {
return 'Sai email hoặc mật khẩu. Vui lòng kiểm tra lại.'
}
if (msg.includes('Email not confirmed')) {
return 'Email chưa được xác nhận.'
}
return 'Đã có lỗi xảy ra. Vui lòng thử lại.'
}

67
src/store/test-store.ts Normal file
View File

@@ -0,0 +1,67 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { SessionPart } from '@/types'
interface StartExamConfig {
testId: number | null
testName: string
parts: SessionPart[]
totalSeconds: number // 0 = no limit
}
interface TestStore {
testId: number | null
testName: string
parts: SessionPart[]
currentPartIndex: number
answers: Record<number, number | null> // questionId → answerIndex (0-3), null=unanswered
isSubmitted: boolean
timeUsed: number // seconds elapsed when submitted
totalSeconds: number // time limit (0 = no limit)
startExam: (config: StartExamConfig) => void
setAnswer: (questionId: number, answerIndex: number) => void
setCurrentPart: (partIndex: number) => void
submitExam: (timeUsed: number) => void
reset: () => void
}
const INITIAL_STATE = {
testId: null,
testName: '',
parts: [],
currentPartIndex: 0,
answers: {},
isSubmitted: false,
timeUsed: 0,
totalSeconds: 0,
}
export const useTestStore = create<TestStore>()(
persist(
(set) => ({
...INITIAL_STATE,
startExam: ({ testId, testName, parts, totalSeconds }) => {
// Pre-fill all question IDs with null (unanswered)
const answers: Record<number, number | null> = {}
for (const part of parts) {
for (const q of part.questions) answers[q.id] = null
}
set({ testId, testName, parts, currentPartIndex: 0, answers, isSubmitted: false, timeUsed: 0, totalSeconds })
},
setAnswer: (questionId, answerIndex) =>
set((state) => ({
answers: { ...state.answers, [questionId]: answerIndex },
})),
setCurrentPart: (partIndex) => set({ currentPartIndex: partIndex }),
submitExam: (timeUsed) => set({ isSubmitted: true, timeUsed }),
reset: () => set(INITIAL_STATE),
}),
{ name: 'test-store' },
),
)

Some files were not shown because too many files have changed in this diff Show More