18 Commits

Author SHA1 Message Date
abfaf397ee update2
Some checks failed
Build and Push Docker Image on Tag / build (push) Failing after 11m8s
2026-04-21 14:33:08 +07:00
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
202 changed files with 224979 additions and 1295 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

View File

@@ -1,10 +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 — https://open.bigmodel.cn/usercenter/apikeys
# Used by the writing-check Supabase Edge Function (server-side only, never expose in frontend)
# Deploy to Supabase with: supabase secrets set GLM_API_KEY=<your_key>
# 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

1
.gitignore vendored
View File

@@ -33,7 +33,6 @@ yarn-error.log*
.pnpm-debug.log*
# package manager
package-lock.json
yarn.lock
pnpm-lock.yaml

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>

159
Claude.md
View File

@@ -2,7 +2,7 @@
> 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**: Merged all decisions — Phase 1 scaffold done, Phase 24 planned
> **Last updated**: Phase 2 done thêm Phase 3 Retention & Monetization, đẩy Speaking AI và Full TOEIC sang Phase 4 & 5
---
@@ -12,7 +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
**Roadmap**: 4 phases — MVP → Auth & Progress → Speaking AI → Full TOEIC
**Roadmap**: 5 phases — MVP → Auth & Progress → Retention & Monetization → Speaking AI → Full TOEIC
---
@@ -349,11 +349,136 @@ Evaluate the writing and return ONLY valid JSON:
---
### PHASE 3 — Speaking AI
### 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 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) |
**Dùng Xu**:
| Tính năng | Chi phí |
|---|---|
| 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.
---
#### 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 |
---
#### 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
#### 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ả lê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 2 ổn định, user quay lại đều đặn
**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
@@ -364,15 +489,15 @@ Evaluate the writing and return ONLY valid JSON:
**Tech mới**:
- Speech-to-text: Whisper API hoặc Google Speech-to-Text
- Text-to-speech: native audio
- WebRTC / MediaRecorder API (web)
- WebRTC / MediaRecorder API (web) + Flutter audio recording
---
### PHASE 4 — Full TOEIC Mock Test
### PHASE 5 — Full TOEIC Mock Test
**Mục tiêu**: Platform luyện TOEIC toàn diện chuẩn ETS
**Trigger**: Phase 3 xong, cần nội dung premium
**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
@@ -389,20 +514,23 @@ Evaluate the writing and return ONLY valid JSON:
| Quyết định | do |
|---|---|
| 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 3+ |
| 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, mobile app khi traction ràng |
| 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 (không mobile-first) | Target TOEIC learner hay dùng máy tính |
| 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 |
| localStorage Phase 1 | Đủ cho MVP, không cần backend phức tạp |
| NestJS Phase 2 | Supabase đủ để validate, NestJS khi scale |
| Speaking AI Phase 3 | Cần infra ổn định trước khi làm realtime audio |
| Full mock test Phase 4 | Cần content team + audio, không phải tech problem |
| 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 |
---
@@ -414,6 +542,7 @@ Evaluate the writing and return ONLY valid JSON:
- **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
---
@@ -427,4 +556,6 @@ Evaluate the writing and return ONLY valid JSON:
| 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 |
| Audio quality Phase 4 | 🟡 TB | Budget cho studio recording hoặc TTS premium |
| 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"]

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

View File

@@ -2,6 +2,7 @@
<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>
@@ -9,6 +10,7 @@
<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>

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

8105
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

1
public/env.js Normal file
View File

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

View File

@@ -1,32 +0,0 @@
import { useRouterState } from '@tanstack/react-router'
import { useTestStore } from '@/store/test-store'
import { UserMenu } from '@/components/UserMenu'
const ROUTE_TITLES: Record<string, string> = {
'/': 'Trang chủ',
'/writing': 'AI Chấm Writing',
'/vocab': 'Từ vựng TOEIC',
'/toeic': 'Luyện đề TOEIC',
'/toeic/session': '', // dynamic — filled below
'/toeic/result': 'Kết quả bài thi',
}
export function AppHeader() {
const { location } = useRouterState()
const { partId, partName, answers, questions } = useTestStore()
const pathname = location.pathname
let title = ROUTE_TITLES[pathname] ?? 'EnglishAI'
if (pathname === '/toeic/session') {
const answered = answers.filter((a) => a !== null).length
title = `Part ${partId}${partName} · ${answered}/${questions.length} câu`
}
return (
<header className="fixed top-0 right-0 left-0 lg:left-60 h-16 bg-white/90 backdrop-blur-md border-b border-slate-200 z-40 flex items-center justify-between px-6">
<span className="text-sm font-semibold text-slate-700">{title}</span>
<UserMenu />
</header>
)
}

View File

@@ -1,84 +0,0 @@
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: '/toeic', label: 'Luyện đề TOEIC', icon: 'assignment', matchPrefix: '/toeic', exact: false },
{ to: '/writing', label: 'AI Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false },
{ to: '/vocab', label: 'Từ vựng', icon: 'menu_book', matchPrefix: '/vocab', 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 bg-slate-50 border-r border-slate-200 z-50">
{/* Brand */}
<div className="px-6 py-5 border-b border-slate-200">
<div className="text-xl font-extrabold text-blue-600 tracking-tight">EnglishAI</div>
<div className="text-xs text-slate-400 mt-0.5">Học tập thông minh</div>
</div>
{/* Nav */}
<nav className="flex-1 py-3 overflow-y-auto">
{NAV_ITEMS.map((item) => {
const active = isActive(pathname, item.matchPrefix, item.exact)
return (
<Link
key={item.to}
to={item.to}
className={cn(
'flex items-center gap-3 mx-2 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150',
active
? 'bg-white text-blue-600 font-semibold shadow-sm'
: 'text-slate-500 hover:bg-white/70 hover:text-slate-800',
)}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
{item.icon}
</span>
{item.label}
</Link>
)
})}
</nav>
{/* User */}
<div className="px-3 py-4 border-t border-slate-200">
{user ? (
<div className="flex items-center gap-3 bg-white rounded-xl px-3 py-2.5">
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-bold flex-shrink-0">
{user.name.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{user.name}</div>
<div className="text-xs text-slate-400 truncate">{user.email}</div>
</div>
</div>
) : (
<button
onClick={() => openModal('login')}
className="w-full flex items-center gap-3 bg-white rounded-xl px-3 py-2.5 hover:bg-blue-50 transition-colors group"
>
<div className="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center flex-shrink-0 group-hover:bg-blue-100 transition-colors">
<span className="material-symbols-outlined text-slate-400 group-hover:text-blue-600 transition-colors" style={{ fontSize: 18 }}>person</span>
</div>
<div className="min-w-0 text-left">
<div className="text-sm font-semibold text-slate-600">Khách</div>
<div className="text-xs text-blue-600 font-medium">Đăng nhập </div>
</div>
</button>
)}
</div>
</aside>
)
}

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

@@ -3,9 +3,10 @@ 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: '/vocab', label: 'Từ vựng', icon: 'menu_book', matchPrefix: '/vocab', exact: false },
{ to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false },
]
function isActive(pathname: string, prefix: string, exact: boolean) {

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

@@ -1,157 +0,0 @@
import type { Question, VocabWord, WritingFeedback, ToeicPart } from '@/types'
export const TOEIC_PARTS: ToeicPart[] = [
{ id: 1, name: 'Part 1', nameVi: 'Mô tả hình ảnh', questionCount: 45, icon: 'image', progressPercent: 60 },
{ id: 2, name: 'Part 2', nameVi: 'Hỏi-đáp', questionCount: 30, icon: 'question_answer', progressPercent: 40 },
{ id: 3, name: 'Part 3', nameVi: 'Đoạn hội thoại', questionCount: 39, icon: 'forum', progressPercent: 25 },
{ id: 4, name: 'Part 4', nameVi: 'Bài nói', questionCount: 30, icon: 'record_voice_over', progressPercent: 10 },
{ id: 5, name: 'Part 5', nameVi: 'Điền từ', questionCount: 40, icon: 'history_edu', progressPercent: 80 },
{ id: 6, name: 'Part 6', nameVi: 'Điền đoạn', questionCount: 16, icon: 'article', progressPercent: 50 },
{ id: 7, name: 'Part 7', nameVi: 'Đọc hiểu', questionCount: 54, icon: 'chrome_reader_mode', progressPercent: 30 },
]
export const MOCK_QUESTIONS: Question[] = [
{
id: 'q1', part: 2,
text: 'What does the man suggest the woman do about the budget report?',
options: ['A. Submit it immediately', 'B. Review it again carefully', 'C. Postpone the deadline', 'D. Ask a colleague for help'],
correctAnswer: 1,
explanation: 'Người đàn ông nói "You should review it carefully before submitting" — gợi ý xem xét lại báo cáo trước khi nộp.',
},
{
id: 'q2', part: 2,
text: 'Where most likely are the speakers?',
options: ['A. In a restaurant', 'B. At a conference', 'C. In an office', 'D. At an airport'],
correctAnswer: 2,
explanation: 'Các từ như "meeting room", "printer", "desk" cho biết cuộc trò chuyện diễn ra trong văn phòng.',
},
{
id: 'q3', part: 2,
text: 'Why is the man calling?',
options: ['A. To confirm a reservation', 'B. To cancel an appointment', 'C. To reschedule a meeting', 'D. To order supplies'],
correctAnswer: 0,
explanation: 'Từ "confirm" và "booking number" trong hội thoại chỉ rõ mục đích của cuộc gọi là xác nhận đặt chỗ.',
},
{
id: 'q4', part: 2,
text: 'What will the woman do next?',
options: ['A. Call the manager', 'B. Send an email', 'C. Check the inventory', 'D. Update the schedule'],
correctAnswer: 3,
explanation: 'Người phụ nữ nói "I\'ll update the schedule right away" — cho biết hành động tiếp theo là cập nhật lịch trình.',
},
{
id: 'q5', part: 2,
text: 'What problem does the man mention?',
options: ['A. A delayed shipment', 'B. A broken device', 'C. A missing document', 'D. A scheduling conflict'],
correctAnswer: 0,
explanation: '"The delivery has been delayed by two days" — vấn đề được đề cập là lô hàng bị trễ.',
},
{
id: 'q6', part: 2,
text: 'How does the woman respond to the proposal?',
options: ['A. She accepts it', 'B. She rejects it', 'C. She needs more time', 'D. She suggests modifications'],
correctAnswer: 3,
explanation: '"That sounds good, but maybe we could adjust the timeline a bit" — đề xuất điều chỉnh, không chấp nhận hoàn toàn.',
},
{
id: 'q7', part: 2,
text: 'What is the purpose of the announcement?',
options: ['A. To introduce new products', 'B. To notify schedule changes', 'C. To welcome new employees', 'D. To announce a promotion'],
correctAnswer: 1,
explanation: 'Thông báo nói về việc thay đổi giờ làm việc từ tuần tới — mục đích là thông báo thay đổi lịch.',
},
{
id: 'q8', part: 2,
text: 'What does the woman ask the man to do?',
options: ['A. Prepare a presentation', 'B. Contact the client', 'C. Review the contract', 'D. Attend a training session'],
correctAnswer: 2,
explanation: '"Could you go over the contract before we sign?" — người phụ nữ yêu cầu xem lại hợp đồng.',
},
{
id: 'q9', part: 2,
text: 'When will the project be completed?',
options: ['A. By the end of this week', 'B. Next Monday', 'C. In two weeks', 'D. Next month'],
correctAnswer: 0,
explanation: '"We should be finished by Friday" — dự án sẽ hoàn thành vào cuối tuần này.',
},
{
id: 'q10', part: 2,
text: 'What is being discussed at the meeting?',
options: ['A. Budget allocations', 'B. Marketing strategies', 'C. Product launches', 'D. Staff promotions'],
correctAnswer: 1,
explanation: 'Cuộc họp tập trung vào "the new advertising campaign and social media approach" — chiến lược marketing.',
},
]
export const VOCAB_DATA: Record<string, VocabWord[]> = {
'Tất cả': [
{ id: 'v1', word: 'negotiate', phonetic: '/nɪˈɡoʊʃieɪt/', meaningVi: 'đàm phán', topic: 'Business', example: 'We need to negotiate the contract terms before signing.' },
{ id: 'v2', word: 'collaborate', phonetic: '/kəˈlæbəreɪt/', meaningVi: 'hợp tác', topic: 'Business', example: 'Teams need to collaborate effectively to meet the deadline.' },
{ id: 'v3', word: 'agenda', phonetic: '/əˈdʒendə/', meaningVi: 'chương trình nghị sự', topic: 'Office', example: 'The agenda has been sent to all meeting participants.' },
{ id: 'v4', word: 'itinerary', phonetic: '/aɪˈtɪnəreri/', meaningVi: 'lịch trình chuyến đi', topic: 'Travel', example: 'Here is your travel itinerary for the business conference.' },
{ id: 'v5', word: 'reimburse', phonetic: '/ˌriːɪmˈːrs/', meaningVi: 'hoàn tiền', topic: 'Finance', example: 'The company will reimburse your travel expenses.' },
{ id: 'v6', word: 'recruit', phonetic: '/rɪˈkruːt/', meaningVi: 'tuyển dụng', topic: 'HR', example: 'We are actively recruiting experienced engineers.' },
{ id: 'v7', word: 'campaign', phonetic: '/kæmˈpeɪn/', meaningVi: 'chiến dịch', topic: 'Marketing', example: 'The marketing campaign was very successful this quarter.' },
{ id: 'v8', word: 'implement', phonetic: '/ˈɪmplɪment/', meaningVi: 'triển khai, thực hiện', topic: 'Business', example: 'We plan to implement the new strategy next quarter.' },
],
'Business': [
{ id: 'b1', word: 'negotiate', phonetic: '/nɪˈɡoʊʃieɪt/', meaningVi: 'đàm phán', topic: 'Business', example: 'We need to negotiate the contract terms.' },
{ id: 'b2', word: 'collaborate', phonetic: '/kəˈlæbəreɪt/', meaningVi: 'hợp tác', topic: 'Business', example: 'Teams collaborate to achieve shared goals.' },
{ id: 'b3', word: 'delegate', phonetic: '/ˈdelɪɡeɪt/', meaningVi: 'uỷ quyền, phân công', topic: 'Business', example: 'A good manager knows how to delegate tasks.' },
{ id: 'b4', word: 'implement', phonetic: '/ˈɪmplɪment/', meaningVi: 'triển khai, thực hiện', topic: 'Business', example: 'We will implement the new policy next month.' },
{ id: 'b5', word: 'merger', phonetic: '/ˈːrdʒər/', meaningVi: 'sáp nhập công ty', topic: 'Business', example: 'The merger will create a stronger combined company.' },
{ id: 'b6', word: 'acquisition', phonetic: '/ˌækwɪˈzɪʃən/', meaningVi: 'mua lại, thâu tóm', topic: 'Business', example: 'The acquisition was completed ahead of schedule.' },
],
'Office': [
{ id: 'o1', word: 'agenda', phonetic: '/əˈdʒendə/', meaningVi: 'chương trình nghị sự', topic: 'Office', example: 'Please review the agenda before the meeting.' },
{ id: 'o2', word: 'minutes', phonetic: '/ˈmɪnɪts/', meaningVi: 'biên bản họp', topic: 'Office', example: 'Could you take the meeting minutes today?' },
{ id: 'o3', word: 'submit', phonetic: '/səbˈmɪt/', meaningVi: 'nộp, gửi đi', topic: 'Office', example: 'Please submit your report by Friday afternoon.' },
{ id: 'o4', word: 'deadline', phonetic: '/ˈdedlaɪn/', meaningVi: 'hạn chót', topic: 'Office', example: 'The deadline for this project is end of month.' },
{ id: 'o5', word: 'cubicle', phonetic: '/ˈkjuːbɪkəl/', meaningVi: 'góc làm việc riêng', topic: 'Office', example: 'Each employee has their own cubicle in the open office.' },
],
'Travel': [
{ id: 't1', word: 'itinerary', phonetic: '/aɪˈtɪnəreri/', meaningVi: 'lịch trình chuyến đi', topic: 'Travel', example: 'Here is your detailed travel itinerary.' },
{ id: 't2', word: 'boarding pass', phonetic: '/ˈːrdɪŋ pæs/', meaningVi: 'thẻ lên máy bay', topic: 'Travel', example: 'Please have your boarding pass ready at the gate.' },
{ id: 't3', word: 'layover', phonetic: '/ˈleɪoʊvər/', meaningVi: 'thời gian quá cảnh', topic: 'Travel', example: 'There is a two-hour layover in Singapore.' },
{ id: 't4', word: 'customs', phonetic: '/ˈkʌstəmz/', meaningVi: 'hải quan', topic: 'Travel', example: 'All passengers must go through customs on arrival.' },
{ id: 't5', word: 'baggage claim', phonetic: '/ˈɡɪdʒ kleɪm/', meaningVi: 'băng chuyền hành lý', topic: 'Travel', example: 'Meet us at the baggage claim after landing.' },
],
'Finance': [
{ id: 'f1', word: 'reimburse', phonetic: '/ˌriːɪmˈːrs/', meaningVi: 'hoàn tiền', topic: 'Finance', example: 'The company will reimburse all travel expenses.' },
{ id: 'f2', word: 'invoice', phonetic: '/ˈɪnvɔɪs/', meaningVi: 'hoá đơn', topic: 'Finance', example: 'Please send the invoice to our accounting department.' },
{ id: 'f3', word: 'budget', phonetic: '/ˈbʌdʒɪt/', meaningVi: 'ngân sách', topic: 'Finance', example: 'We need to stay within the approved budget.' },
{ id: 'f4', word: 'revenue', phonetic: '/ˈrevɪnjuː/', meaningVi: 'doanh thu', topic: 'Finance', example: 'Revenue increased by 15% last quarter.' },
{ id: 'f5', word: 'fiscal year', phonetic: '/ˈfɪskəl jɪər/', meaningVi: 'năm tài chính', topic: 'Finance', example: 'Our fiscal year ends on December 31st.' },
],
'HR': [
{ id: 'h1', word: 'recruit', phonetic: '/rɪˈkruːt/', meaningVi: 'tuyển dụng', topic: 'HR', example: 'We are recruiting experienced software engineers.' },
{ id: 'h2', word: 'probation', phonetic: '/proʊˈbeɪʃən/', meaningVi: 'thử việc', topic: 'HR', example: 'New employees have a 3-month probation period.' },
{ id: 'h3', word: 'appraisal', phonetic: '/əˈpreɪzəl/', meaningVi: 'đánh giá nhân viên', topic: 'HR', example: 'Annual performance appraisals are held in December.' },
{ id: 'h4', word: 'resignation', phonetic: '/ˌrezɪɡˈneɪʃən/', meaningVi: 'đơn từ chức', topic: 'HR', example: 'She submitted her resignation letter this morning.' },
{ id: 'h5', word: 'onboarding', phonetic: '/ˈɒnbɔːrdɪŋ/', meaningVi: 'quy trình tiếp nhận nhân viên mới', topic: 'HR', example: 'The onboarding process takes about two weeks.' },
],
'Marketing': [
{ id: 'm1', word: 'campaign', phonetic: '/kæmˈpeɪn/', meaningVi: 'chiến dịch', topic: 'Marketing', example: 'The marketing campaign exceeded all expectations.' },
{ id: 'm2', word: 'demographics', phonetic: '/ˌdeməˈɡræfɪks/', meaningVi: 'nhân khẩu học', topic: 'Marketing', example: 'We need to understand our target demographics.' },
{ id: 'm3', word: 'endorse', phonetic: '/ɪnˈːrs/', meaningVi: 'chứng thực, bảo trợ', topic: 'Marketing', example: 'The product is endorsed by professional athletes.' },
{ id: 'm4', word: 'branding', phonetic: '/ˈbrændɪŋ/', meaningVi: 'xây dựng thương hiệu', topic: 'Marketing', example: 'Consistent branding builds long-term customer trust.' },
{ id: 'm5', word: 'conversion rate', phonetic: '/kənˈːrʒən reɪt/', meaningVi: 'tỷ lệ chuyển đổi', topic: 'Marketing', example: 'Our conversion rate improved after the redesign.' },
],
}
export const MOCK_WRITING_FEEDBACK: WritingFeedback = {
score: '6.5',
grammar: [
'"managers are concern" → nên dùng "concerned" (tính từ, không phải danh từ)',
'Thiếu mạo từ "an" trước "efficient arrangement" ở câu cuối',
'Câu "This change is expected to improve" — đúng nhưng hơi thụ động, có thể dùng active voice',
],
vocabulary: [
'Tốt: "implement", "productivity", "collaboration", "arrangement"',
'Gợi ý nâng cao: "enhance" thay "increase", "address" thay "help with"',
'Nên thêm từ nối: "Nevertheless", "In addition", "As a result of this"',
],
structure: 'Bài viết có cấu trúc khá rõ ràng với mở đầu, thân bài và kết luận ngầm. Tuy nhiên cần phát triển thêm phần giải thích tác động và thêm ví dụ cụ thể để bài hoàn chỉnh hơn.',
improvedVersion: 'The company has decided to implement a new remote work policy starting next month. All employees will be able to work from home for three days per week. This change is expected to enhance work-life balance and boost overall productivity. Nevertheless, some managers are concerned about communication challenges and team collaboration. To address these concerns, the HR department will organize training sessions to help teams adapt to this new arrangement effectively.',
summary: 'Bài viết đạt mức Upper Intermediate (6.5) với ý tưởng rõ ràng. Cần sửa lỗi ngữ pháp cơ bản và bổ sung từ vựng phong phú hơn để đạt band 7.0+.',
}

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useUser } from '@/hooks/use-auth'
import { LoginForm } from '@/components/auth/LoginForm'
import { LoginForm } from './LoginForm'
export function LoginPage() {
const user = useUser()

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useUser } from '@/hooks/use-auth'
import { RegisterForm } from '@/components/auth/RegisterForm'
import { RegisterForm } from './RegisterForm'
export function RegisterPage() {
const user = useUser()

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,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

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { FlashCard } from '@/components/FlashCard'
import { FlashCard } from './FlashCard'
import { useVocabStore } from '@/store/vocab-store'
import { useVocab } from '@/hooks/use-vocab'
import { VOCAB_TOPICS } from '@/types'
@@ -170,7 +170,6 @@ export function Vocabulary() {
Trước
</button>
{/* Mark buttons */}
<div className="flex gap-2">
<button
onClick={handleMarkReview}
@@ -203,7 +202,6 @@ export function Vocabulary() {
{/* Right: Stats — desktop only */}
<div className="hidden lg:flex flex-col gap-4 w-52 flex-shrink-0">
{/* Today stats */}
<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">
@@ -229,7 +227,6 @@ export function Vocabulary() {
</div>
</div>
{/* Recently known */}
{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>

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,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

@@ -2,8 +2,8 @@ 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 '@/data/mock-data'
import { fetchQuestions } from '@/hooks/use-questions'
import { TOEIC_PARTS } from '@/temp/local-data'
import { fetchQuestionsForTest } from '@/hooks/use-questions'
import { useRequireAuth } from '@/hooks/use-require-auth'
export function ToeicPractice() {
@@ -16,8 +16,9 @@ export function ToeicPractice() {
if (!requireAuth()) return
setLoadingPartId(partId)
try {
const questions = await fetchQuestions(partId, 10)
startExam(partId, partName, questions)
// 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)

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

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

@@ -1,35 +1,96 @@
import { useQuery } from "@tanstack/react-query"
import { supabase } from "@/lib/supabase"
import type { Question } from "@/types"
import type { Question, SessionPart } from "@/types"
const ANSWER_INDEX: Record<string, number> = { A: 0, B: 1, C: 2, D: 3 }
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 }
// Maps a Supabase row to the shared Question interface.
// DB uses `content` + `answer` ('A''D'); interface uses `text` + `correctAnswer` (03).
function rowToQuestion(row: Record<string, unknown>): Question {
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 as string,
part: row.part as number,
text: row.content as string,
options: row.options as string[],
correctAnswer: ANSWER_INDEX[(row.answer as string).toUpperCase()] ?? 0,
explanation: (row.explanation as string) ?? '',
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,
}
}
// Exported for imperative use (e.g. ToeicPractice click handler).
// part=0 fetches all parts (Full Test).
export async function fetchQuestions(part: number, limit = 10): Promise<Question[]> {
let query = supabase.from('questions').select('*').limit(limit)
if (part > 0) query = query.eq('part', part)
const { data, error } = await query
if (error) throw error
return (data ?? []).map(rowToQuestion)
}
/**
* 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 []
export function useQuestions(part: number, limit = 10) {
return useQuery({
queryKey: ['questions', part, limit],
queryFn: () => fetchQuestions(part, limit),
})
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

@@ -1,47 +1,135 @@
import { useMutation } from "@tanstack/react-query"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { canUseWritingCheck, recordWritingCheckUsage } from "@/utils/rate-limiter"
import { useAuthStore } from "@/store/auth-store"
import { supabase } from "@/lib/supabase"
import { saveWritingSubmission, countTodayWritingSubmissions } from "@/lib/progress-service"
import type { WritingFeedback } from "@/types"
const AUTH_DAILY_LIMIT = 10
const GUEST_DAILY_LIMIT = 3
async function callEdgeFunction(content: string): Promise<WritingFeedback> {
const { data, error } = await supabase.functions.invoke<WritingFeedback>("writing-check", {
body: { content },
// 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 (error) throw new Error(error.message ?? "Đã có lỗi khi chấm bài. Vui lòng thử lại.")
if (!data) throw new Error("Phản hồi từ AI không hợp lệ. Vui lòng thử lại.")
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.")
}
return data
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: string): Promise<WritingFeedback> => {
mutationFn: async ({
content,
onChunk,
}: {
content: string
onChunk?: (text: string) => void
}): Promise<WritingFeedback> => {
const user = useAuthStore.getState().user
if (user) {
// Server-side rate limit for authenticated users (10/day)
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 {
// localStorage rate limit for guests (3/day)
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)
const feedback = await callEdgeFunction(content, onChunk)
if (user) {
// Save submission to DB (fire-and-forget)
saveWritingSubmission(user.id, content, feedback)
await saveWritingSubmission(user.id, content, feedback)
queryClient.invalidateQueries({ queryKey: ["writing-history"] })
} else {
recordWritingCheckUsage()
}

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

View File

@@ -48,6 +48,36 @@
}
: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);
@@ -121,13 +151,106 @@
@apply border-border outline-ring/50;
}
body {
@apply bg-slate-50 text-slate-800;
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;
@@ -173,4 +296,209 @@
}
.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

@@ -1,22 +1,47 @@
import { supabase } from '@/lib/supabase'
const ANSWER_VALUES = ['A', 'B', 'C', 'D'] as const
interface TestResultData {
partId: number
partName: string
testId: number | null
selectedParts: number[]
score: number
total: number
timeUsed: number
answers: { questionId: string; selected: number | null; correct: boolean }[]
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 { error } = await supabase.from('user_progress').insert({
user_id: userId,
type: 'test',
data,
})
if (error) console.error('Failed to save test result:', error.message)
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. */
@@ -48,10 +73,9 @@ export async function countTodayWritingSubmissions(userId: string): Promise<numb
/** Fetch test history for a user (most recent first, max 20). */
export async function fetchTestHistory(userId: string) {
const { data, error } = await supabase
.from('user_progress')
.select('*')
.from('user_test_attempt')
.select('id, selected_parts, time_spent_seconds, total_correct, total_questions, score, submitted_at, created_at')
.eq('user_id', userId)
.eq('type', 'test')
.order('created_at', { ascending: false })
.limit(20)
if (error) throw error

View File

@@ -1,8 +1,15 @@
import { createClient } from "@supabase/supabase-js"
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
// Supports both key name conventions
// 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

View File

@@ -1,204 +0,0 @@
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',
iconBg: 'bg-blue-50',
iconColor: 'text-blue-600',
borderColor: 'border-l-blue-600',
title: 'Luyện đề 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 chi tiết từng Part.',
cta: 'Bắt đầu ngay',
ctaColor: 'text-blue-600',
stat: '350+ câu hỏi',
},
{
to: '/writing',
icon: 'auto_fix_high',
iconBg: 'bg-green-50',
iconColor: 'text-green-600',
borderColor: 'border-l-green-600',
title: 'AI Chấm 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 từ AI.',
cta: 'Thử ngay',
ctaColor: 'text-green-600',
stat: '3 lượt / ngày',
},
{
to: '/vocab',
icon: 'menu_book',
iconBg: 'bg-amber-50',
iconColor: 'text-amber-600',
borderColor: 'border-l-amber-600',
title: 'Từ vựng thông minh',
desc: '720 từ TOEIC theo 6 chủ đề. Flashcard với hiệu ứng lật 3D.',
cta: 'Khám phá',
ctaColor: 'text-amber-600',
stat: '720 từ vựng',
},
]
export function Home() {
const user = useUser()
const openModal = useAuthModalStore((s) => s.open)
return (
<div className="px-6 py-8 max-w-6xl mx-auto page-enter">
{/* Hero */}
<section className="flex flex-col lg:flex-row gap-10 items-center mb-12">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center gap-2 bg-blue-50 text-blue-600 text-xs font-bold px-3 py-1.5 rounded-full mb-5 uppercase tracking-wider">
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>auto_awesome</span>
AI-Powered Learning
</div>
<h1 className="text-4xl lg:text-5xl font-extrabold leading-tight text-slate-800 mb-4" style={{ letterSpacing: '-0.02em' }}>
Luyện TOEIC<br />thông minh<br />
<span className="text-blue-600 italic">cùng AI</span>
</h1>
<p className="text-slate-500 text-lg leading-relaxed mb-8 max-w-md">
nhân hóa lộ trình học tập đ bứt phá điểm số trong thời gian ngắn nhất. AI phân tích điểm yếu tối ưu bài tập cho bạn.
</p>
<div className="flex gap-3 flex-wrap">
<Link
to="/toeic"
className="bg-blue-600 text-white px-8 py-3.5 rounded-xl font-bold text-sm hover:bg-blue-700 transition-colors shadow-lg shadow-blue-600/20"
>
Bắt đu ngay
</Link>
<Link
to="/writing"
className="border border-slate-200 px-8 py-3.5 rounded-xl font-bold text-sm text-slate-500 hover:bg-white hover:border-blue-600 hover:text-blue-600 transition-all"
>
Thử AI Writing
</Link>
</div>
<div className="flex gap-6 mt-8">
<div>
<div className="text-2xl font-extrabold text-blue-600">350+</div>
<div className="text-xs text-slate-400 mt-0.5">Câu hỏi TOEIC</div>
</div>
<div className="w-px bg-slate-200" />
<div>
<div className="text-2xl font-extrabold text-green-600">720</div>
<div className="text-xs text-slate-400 mt-0.5">Từ vựng</div>
</div>
<div className="w-px bg-slate-200" />
<div>
<div className="text-2xl font-extrabold text-amber-600">AI</div>
<div className="text-xs text-slate-400 mt-0.5">Writing Checker</div>
</div>
</div>
</div>
{/* Preview card — hidden on mobile */}
<div className="hidden lg:block flex-shrink-0 w-80">
<div className="bg-white rounded-2xl p-6 shadow-xl border border-slate-100">
<div className="flex items-center justify-between mb-5">
<div>
<div className="font-bold text-base text-slate-800">Tiến đ tuần này</div>
<div className="text-xs text-slate-400 mt-0.5">Bạn đang làm rất tốt!</div>
</div>
<div className="bg-green-50 text-green-600 text-xs font-bold px-2.5 py-1 rounded-lg">+12%</div>
</div>
<div className="mb-4">
<div className="flex justify-between text-xs font-semibold mb-1.5">
<span>Reading Score</span><span className="text-blue-600">420/495</span>
</div>
<div className="h-1.5 w-full rounded-full bg-slate-100">
<div className="h-full bg-blue-600 rounded-full" style={{ width: '85%' }} />
</div>
</div>
<div className="mb-4">
<div className="flex justify-between text-xs font-semibold mb-1.5">
<span>Listening Score</span><span className="text-green-600">380/495</span>
</div>
<div className="h-1.5 w-full rounded-full bg-slate-100">
<div className="h-full bg-green-600 rounded-full" style={{ width: '77%' }} />
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-4">
<div className="bg-blue-50 rounded-xl p-3 border-l-4 border-blue-600">
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 18 }}>local_fire_department</span>
<div className="text-xl font-extrabold text-blue-600 mt-1">14</div>
<div className="text-xs text-slate-400">Ngày Streak</div>
</div>
<div className="bg-green-50 rounded-xl p-3 border-l-4 border-green-600">
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 18, fontVariationSettings: "'FILL' 1" }}>star</span>
<div className="text-xl font-extrabold text-green-600 mt-1">1,250</div>
<div className="text-xs text-slate-400">Điểm tích lũy</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-slate-100 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 16 }}>psychology</span>
</div>
<p className="text-xs text-slate-500">
<span className="font-semibold">AI gợi ý:</span> Ôn thêm Part 5 Ngữ pháp
</p>
</div>
</div>
</div>
</section>
{/* Feature cards */}
<section>
<h2 className="text-2xl font-extrabold text-slate-800 mb-1.5">Tính năng nổi bật</h2>
<p className="text-slate-500 mb-6">Hệ sinh thái học tập toàn diện đưc thiết kế đ tối ưu hoá điểm số.</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{FEATURES.map((f) => (
<Link
key={f.to}
to={f.to}
className={`bg-white rounded-2xl p-6 border border-slate-200 border-l-4 ${f.borderColor} hover:-translate-y-1 hover:shadow-md transition-all duration-200`}
>
<div className={`w-12 h-12 ${f.iconBg} rounded-xl flex items-center justify-center mb-4`}>
<span className={`material-symbols-outlined ${f.iconColor}`}>{f.icon}</span>
</div>
<h3 className="font-bold text-base text-slate-800 mb-2">{f.title}</h3>
<p className="text-slate-500 text-sm leading-relaxed mb-4">{f.desc}</p>
<div className={`flex items-center gap-1.5 text-sm font-bold ${f.ctaColor}`}>
{f.cta}
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>arrow_forward</span>
</div>
</Link>
))}
</div>
</section>
{/* CTA banner */}
<section className="mt-10">
<div className="bg-blue-600 rounded-2xl p-8 flex items-center justify-between overflow-hidden relative">
<div className="absolute right-4 top-0 bottom-0 flex items-center opacity-10">
<span className="material-symbols-outlined text-white" style={{ fontSize: 120 }}>emoji_events</span>
</div>
<div className="relative z-10">
<h3 className="text-2xl font-extrabold text-white mb-2">Sẵn sàng chinh phục 990 TOEIC?</h3>
<p className="text-blue-100 mb-5">
{user
? `Chào ${user.name}! Tiếp tục luyện thi hôm nay.`
: 'Đăng ký miễn phí để lưu tiến độ và luyện thi không giới hạn.'}
</p>
{user ? (
<Link
to="/toeic"
className="inline-block bg-white text-blue-600 px-6 py-3 rounded-xl font-bold text-sm hover:bg-blue-50 transition-colors"
>
Luyện thi ngay
</Link>
) : (
<button
onClick={() => openModal('register')}
className="bg-white text-blue-600 px-6 py-3 rounded-xl font-bold text-sm hover:bg-blue-50 transition-colors"
>
Đăng miễn phí
</button>
)}
</div>
</div>
</section>
</div>
)
}

View File

@@ -1,223 +0,0 @@
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'
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 { partId, partName, questions, answers, timeUsed, reset } = useTestStore()
const { isAuthenticated, isLoading } = useRequireAuth()
const user = useAuthStore((s) => s.user)
const savedRef = useRef(false)
useEffect(() => {
if (isLoading) return
if (!isAuthenticated) navigate({ to: '/toeic' })
}, [isLoading, isAuthenticated, navigate])
// Save test result once when page mounts (fire-and-forget)
useEffect(() => {
if (!user || savedRef.current || questions.length === 0) return
savedRef.current = true
saveTestResult(user.id, {
partId,
partName,
score: answers.filter((a, i) => a === questions[i]?.correctAnswer).length,
total: questions.length,
timeUsed,
answers: questions.map((q, i) => ({
questionId: q.id,
selected: answers[i],
correct: answers[i] === q.correctAnswer,
})),
})
}, [user, questions, answers, partId, partName, timeUsed])
const correct = answers.filter((a, i) => a === questions[i]?.correctAnswer).length
const wrong = answers.filter((a, i) => a !== null && a !== questions[i]?.correctAnswer).length
const skipped = answers.filter((a) => a === null).length
const total = questions.length
const percent = total > 0 ? Math.round((correct / total) * 100) : 0
const circumference = 2 * Math.PI * 52
const offset = circumference - (percent / 100) * circumference
function handleRetry() {
navigate({ to: '/toeic/session' })
}
function handleHome() {
reset()
navigate({ to: '/' })
}
if (questions.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 Part đ luyện thi
</button>
</div>
)
}
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">
{/* Circle */}
<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>
{/* Stats */}
<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">
Part {partId} {partName}
</div>
<div className="flex flex-wrap gap-3 justify-center lg:justify-start">
<div className="bg-green-50 border border-green-100 rounded-xl px-4 py-2 text-center">
<div className="text-xl font-extrabold text-green-600">{correct}</div>
<div className="text-xs text-slate-400">Đúng</div>
</div>
<div className="bg-red-50 border border-red-100 rounded-xl px-4 py-2 text-center">
<div className="text-xl font-extrabold text-red-600">{wrong}</div>
<div className="text-xs text-slate-400">Sai</div>
</div>
<div className="bg-slate-50 border border-slate-200 rounded-xl px-4 py-2 text-center">
<div className="text-xl font-extrabold text-slate-500">{skipped}</div>
<div className="text-xs text-slate-400">Bỏ qua</div>
</div>
<div className="bg-blue-50 border border-blue-100 rounded-xl px-4 py-2 text-center">
<div className="text-xl font-extrabold text-blue-600">{formatTime(timeUsed)}</div>
<div className="text-xs text-slate-400">Thời gian</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex lg:flex-col gap-3 flex-shrink-0">
<button
onClick={handleRetry}
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={handleHome}
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 */}
<div className="bg-white rounded-2xl border border-slate-200 p-6">
<h2 className="text-base font-bold text-slate-800 mb-4">Xem lại đáp án</h2>
<div className="space-y-4">
{questions.map((q, i) => {
const userAnswer = answers[i]
const isCorrect = userAnswer === q.correctAnswer
const isSkipped = userAnswer === null
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">
<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',
)}
>
{['A', 'B', 'C', 'D'][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

@@ -1,207 +0,0 @@
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'
const TOTAL_SECONDS = 600 // 10 minutes
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
function formatTime(s: number) {
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
}
export function TestSession() {
const navigate = useNavigate()
const { partId, partName, questions, answers, setAnswer, submitExam } = useTestStore()
const [currentQ, setCurrentQ] = useState(0)
const [timeLeft, setTimeLeft] = useState(TOTAL_SECONDS)
const { isAuthenticated, isLoading } = useRequireAuth()
const handleSubmit = useCallback(() => {
submitExam(TOTAL_SECONDS - timeLeft)
navigate({ to: '/toeic/result' })
}, [submitExam, navigate, timeLeft])
// Countdown
useEffect(() => {
if (questions.length === 0) return
const id = setInterval(() => {
setTimeLeft((t) => {
if (t <= 1) { clearInterval(id); handleSubmit(); return 0 }
return t - 1
})
}, 1000)
return () => clearInterval(id)
}, [questions.length, handleSubmit])
// Redirect if no exam started or not authenticated (wait for auth init)
useEffect(() => {
if (isLoading) return
if (!isAuthenticated || questions.length === 0) navigate({ to: '/toeic' })
}, [isLoading, isAuthenticated, questions.length, navigate])
if (questions.length === 0) return null
const question = questions[currentQ]
const answeredCount = answers.filter((a) => a !== null).length
const isUrgent = timeLeft < 60
return (
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
{/* Mobile progress bar */}
<div className="lg:hidden mb-4">
<div className="flex justify-between text-sm font-semibold mb-2">
<span className="text-slate-700">Part {partId} Câu {currentQ + 1}/{questions.length}</span>
<span className={cn('font-bold tabular-nums', isUrgent ? 'text-red-600 timer-urgent' : 'text-blue-600')}>
{formatTime(timeLeft)}
</span>
</div>
<div className="h-1.5 w-full rounded-full bg-slate-200">
<div
className="h-full bg-blue-600 rounded-full transition-all"
style={{ width: `${((currentQ + 1) / questions.length) * 100}%` }}
/>
</div>
</div>
<div className="flex gap-5">
{/* Left: Question */}
<div className="flex-1 min-w-0">
<div className="bg-white rounded-2xl p-6 border border-slate-200 mb-4">
<div className="flex items-center gap-2 mb-4">
<span className="bg-blue-600 text-white text-xs font-bold px-3 py-1 rounded-full">
Câu {currentQ + 1}
</span>
<span className="text-xs text-slate-400">Part {partId} {partName}</span>
</div>
<p className="text-base font-medium text-slate-800 leading-relaxed mb-6">
{question.text}
</p>
<div className="space-y-3">
{question.options.map((opt, i) => {
const selected = answers[currentQ] === i
return (
<button
key={i}
onClick={() => setAnswer(currentQ, i)}
className={cn(
'w-full flex items-center gap-3 p-4 border-2 rounded-xl text-sm font-medium text-left transition-all',
selected
? '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',
selected ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-500',
)}
>
{ANSWER_LABELS[i]}
</span>
{opt}
</button>
)
})}
</div>
</div>
{/* Navigation */}
<div className="flex items-center justify-between">
<button
onClick={() => setCurrentQ((q) => Math.max(0, q - 1))}
disabled={currentQ === 0}
className="flex items-center gap-2 px-5 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>
Câu trước
</button>
<span className="text-xs text-slate-400 tabular-nums">{currentQ + 1} / {questions.length}</span>
{currentQ < questions.length - 1 ? (
<button
onClick={() => setCurrentQ((q) => q + 1)}
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"
>
Câu tiếp theo
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>chevron_right</span>
</button>
) : (
<button
onClick={handleSubmit}
className="flex items-center gap-2 px-5 py-2.5 bg-red-600 text-white rounded-xl text-sm font-semibold hover:bg-red-700 transition-colors"
>
Nộp bài
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>send</span>
</button>
)}
</div>
</div>
{/* Right panel — desktop only */}
<div className="hidden lg:flex flex-col gap-4 w-60 flex-shrink-0">
{/* Timer */}
<div className="bg-white rounded-2xl p-5 border border-slate-200 text-center">
<div className="text-xs text-slate-400 font-medium mb-2">Thời gian còn lại</div>
<div
className={cn(
'text-5xl font-extrabold tabular-nums mb-1',
isUrgent ? 'text-red-600 timer-urgent' : 'text-blue-600',
)}
>
{formatTime(timeLeft)}
</div>
<div className="text-xs text-slate-400">phút : giây</div>
</div>
{/* Question dots */}
<div className="bg-white rounded-2xl p-5 border border-slate-200">
<div className="text-xs text-slate-400 font-medium mb-3">
Danh sách câu · {answeredCount}/{questions.length} đã trả lời
</div>
<div className="grid grid-cols-5 gap-2">
{questions.map((_, i) => (
<button
key={i}
onClick={() => setCurrentQ(i)}
className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-[11px] font-semibold transition-all',
i === currentQ
? 'border-2 border-blue-600 text-blue-600 shadow-sm shadow-blue-600/20'
: answers[i] !== null
? 'bg-blue-600 text-white'
: 'border-2 border-slate-200 text-slate-400 hover:border-blue-300',
)}
>
{i + 1}
</button>
))}
</div>
<div className="flex items-center gap-3 mt-4 text-xs text-slate-400">
<span className="w-4 h-4 rounded-full bg-blue-600 inline-block" /> Đã trả lời
<span className="w-4 h-4 rounded-full border-2 border-slate-200 inline-block" /> Chưa làm
</div>
</div>
<button
onClick={handleSubmit}
className="w-full py-3 bg-red-600 text-white rounded-xl font-bold text-sm hover:bg-red-700 transition-colors flex items-center justify-center gap-2"
>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>send</span>
Nộp bài
</button>
</div>
</div>
{/* Mobile submit */}
<div className="lg:hidden mt-4">
<button
onClick={handleSubmit}
className="w-full py-3.5 bg-red-600 text-white rounded-xl font-bold text-sm hover:bg-red-700 transition-colors"
>
Nộp bài ngay
</button>
</div>
</div>
)
}

View File

@@ -1,232 +0,0 @@
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'
const MAX_CHARS = 1000
const GUEST_LIMIT = 3
const AUTH_LIMIT = 10
export function WritingChecker() {
const [text, setText] = useState('')
const [improvedExpanded, setImprovedExpanded] = useState(false)
const [remaining, setRemaining] = useState(getRemainingChecks)
const { mutate: checkWriting, isPending, isError, error, data: feedback } = useWritingCheck()
const { requireAuth } = useRequireAuth()
const user = useAuthStore((s) => s.user)
const dailyLimit = user ? AUTH_LIMIT : GUEST_LIMIT
// Fetch server-side remaining count for authenticated users
useEffect(() => {
if (!user) {
setRemaining(getRemainingChecks())
return
}
countTodayWritingSubmissions(user.id).then((used) => {
setRemaining(AUTH_LIMIT - used)
})
}, [user])
const charCount = text.length
const canSubmit = text.trim().length > 0 && remaining > 0 && charCount <= MAX_CHARS && !isPending
function handleSubmit() {
if (!requireAuth()) return
if (!canSubmit) return
checkWriting(text, {
onSuccess: () => {
if (user) {
countTodayWritingSubmissions(user.id).then((used) => setRemaining(AUTH_LIMIT - used))
} else {
setRemaining(getRemainingChecks())
}
},
onError: () => {
if (!user) setRemaining(getRemainingChecks())
},
})
}
return (
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
<div className="mb-6">
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">AI Chấm Writing</h1>
<p className="text-slate-500 text-sm">Nhận phản hồi tức thì về ngữ pháp, từ vựng cấu trúc bài viết.</p>
</div>
<div className="flex flex-col lg:flex-row gap-5">
{/* Left: Input */}
<div className="flex-1 min-w-0">
<div className="bg-white rounded-2xl border border-slate-200 p-5">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold text-slate-700">Bài writing của bạn</span>
<span className={cn('text-xs tabular-nums', charCount > MAX_CHARS ? 'text-red-500 font-bold' : 'text-slate-400')}>
{charCount}/{MAX_CHARS}
</span>
</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value.slice(0, MAX_CHARS))}
rows={12}
placeholder="Nhập bài writing của bạn vào đây... (TOEIC email, IELTS task, hoặc đoạn văn tự do)"
className="w-full resize-none rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-blue-400 focus:bg-white transition-colors"
/>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 14 }}>info</span>
<span className={cn('text-xs font-medium', remaining <= 1 ? 'text-red-500' : 'text-slate-400')}>
Còn {remaining}/{dailyLimit} lượt hôm nay
</span>
</div>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
'flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-bold transition-all',
canSubmit
? 'bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/20'
: 'bg-slate-100 text-slate-400 cursor-not-allowed',
)}
>
{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: 18 }}>auto_fix_high</span>
Chấm bài ngay
</>
)}
</button>
</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="lg:w-80 flex-shrink-0">
{!feedback && !isPending && (
<div className="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col items-center justify-center text-center h-full min-h-48">
<span className="material-symbols-outlined text-slate-300 mb-3" style={{ fontSize: 48 }}>auto_fix_high</span>
<p className="text-sm text-slate-400">Nhập bài nhấn "Chấm bài ngay" đ nhận phản hồi từ AI</p>
</div>
)}
{isPending && (
<div className="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col items-center justify-center text-center h-full min-h-48">
<div className="w-10 h-10 border-2 border-blue-100 border-t-blue-600 rounded-full animate-spin mb-4" />
<p className="text-sm text-slate-500 font-medium">AI đang phân tích bài viết...</p>
<p className="text-xs text-slate-400 mt-1">Thường mất 35 giây</p>
</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

@@ -1,9 +1,9 @@
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { useEffect } from 'react'
import { Sidebar } from '@/components/Sidebar'
import { AppHeader } from '@/components/AppHeader'
import { MobileNav } from '@/components/MobileNav'
import { AuthModal } from '@/components/auth/AuthModal'
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({

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

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

View File

@@ -1,5 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { RegisterPage } from '@/pages/Register'
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 />,
})

View File

@@ -1,5 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"
import { Home } from "@/pages/Home"
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

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

View File

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

View File

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

View File

@@ -1,6 +0,0 @@
import { createFileRoute } from "@tanstack/react-router"
import { Vocabulary } from "@/pages/Vocabulary"
export const Route = createFileRoute("/vocab")({
component: Vocabulary,
})

View File

@@ -1,6 +1,16 @@
import { createFileRoute } from "@tanstack/react-router"
import { WritingChecker } from "@/pages/WritingChecker"
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: WritingChecker,
component: WritingPage,
})

View File

@@ -1,52 +1,66 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { Question } from '@/types'
import type { SessionPart } from '@/types'
interface StartExamConfig {
testId: number | null
testName: string
parts: SessionPart[]
totalSeconds: number // 0 = no limit
}
interface TestStore {
partId: number
partName: string
questions: Question[]
answers: (number | null)[]
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
timeUsed: number // seconds elapsed when submitted
totalSeconds: number // time limit (0 = no limit)
startExam: (partId: number, partName: string, questions: Question[]) => void
setAnswer: (questionIndex: number, answerIndex: number) => void
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) => ({
partId: 2,
partName: '',
questions: [],
answers: [],
isSubmitted: false,
timeUsed: 0,
...INITIAL_STATE,
startExam: (partId, partName, questions) =>
set({
partId,
partName,
questions,
answers: new Array(questions.length).fill(null),
isSubmitted: false,
timeUsed: 0,
}),
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: (questionIndex, answerIndex) =>
set((state) => {
const answers = [...state.answers]
answers[questionIndex] = answerIndex
return { answers }
}),
setAnswer: (questionId, answerIndex) =>
set((state) => ({
answers: { ...state.answers, [questionId]: answerIndex },
})),
setCurrentPart: (partIndex) => set({ currentPartIndex: partIndex }),
submitExam: (timeUsed) => set({ isSubmitted: true, timeUsed }),
reset: () =>
set({ partId: 2, partName: '', questions: [], answers: [], isSubmitted: false, timeUsed: 0 }),
reset: () => set(INITIAL_STATE),
}),
{ name: 'test-store' },
),

132
src/temp/local-data.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* TEMPORARY LOCAL DATA
* ====================
* This file contains all placeholder, mock, and locally-hardcoded data.
* Each export is tagged with its status:
*
* [ACTIVE TEMP] — still used in production code; needs real DB data to replace it
* [UNUSED] — not imported anywhere; kept as reference until Supabase seed is confirmed
* [SUPERSEDED] — replaced by real DB service, kept for reference only
*
* When real database data is available, remove the relevant export and update the consumer.
*/
import type { VocabWord, WritingFeedback, ToeicPart } from '@/types'
// ─── [ACTIVE TEMP] ─────────────────────────────────────────────────────────────
// Used by: src/pages/ToeicPractice.tsx
// Replace with: DB query on `questions` table (group by part, count) + user progress %
// Note: `progressPercent` is hardcoded — user's real per-part progress should come from user_progress
export const TOEIC_PARTS: ToeicPart[] = [
{ id: 1, name: 'Part 1', nameVi: 'Mô tả hình ảnh', questionCount: 45, icon: 'image', progressPercent: 60 },
{ id: 2, name: 'Part 2', nameVi: 'Hỏi-đáp', questionCount: 30, icon: 'question_answer', progressPercent: 40 },
{ id: 3, name: 'Part 3', nameVi: 'Đoạn hội thoại', questionCount: 39, icon: 'forum', progressPercent: 25 },
{ id: 4, name: 'Part 4', nameVi: 'Bài nói', questionCount: 30, icon: 'record_voice_over', progressPercent: 10 },
{ id: 5, name: 'Part 5', nameVi: 'Điền từ', questionCount: 40, icon: 'history_edu', progressPercent: 80 },
{ id: 6, name: 'Part 6', nameVi: 'Điền đoạn', questionCount: 16, icon: 'article', progressPercent: 50 },
{ id: 7, name: 'Part 7', nameVi: 'Đọc hiểu', questionCount: 54, icon: 'chrome_reader_mode', progressPercent: 30 },
]
// ─── [UNUSED] ──────────────────────────────────────────────────────────────────
// Real questions come from Supabase via fetchQuestionsForTest() in src/hooks/use-questions.ts
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const MOCK_QUESTIONS: any[] = [
{ id: 'q1', part: 2, text: 'What does the man suggest the woman do about the budget report?', options: ['A. Submit it immediately', 'B. Review it again carefully', 'C. Postpone the deadline', 'D. Ask a colleague for help'], correctAnswer: 1, explanation: 'Người đàn ông nói "You should review it carefully before submitting" — gợi ý xem xét lại báo cáo trước khi nộp.' },
{ id: 'q2', part: 2, text: 'Where most likely are the speakers?', options: ['A. In a restaurant', 'B. At a conference', 'C. In an office', 'D. At an airport'], correctAnswer: 2, explanation: 'Các từ như "meeting room", "printer", "desk" cho biết cuộc trò chuyện diễn ra trong văn phòng.' },
{ id: 'q3', part: 2, text: 'Why is the man calling?', options: ['A. To confirm a reservation', 'B. To cancel an appointment', 'C. To reschedule a meeting', 'D. To order supplies'], correctAnswer: 0, explanation: 'Từ "confirm" và "booking number" trong hội thoại chỉ rõ mục đích của cuộc gọi là xác nhận đặt chỗ.' },
{ id: 'q4', part: 2, text: 'What will the woman do next?', options: ['A. Call the manager', 'B. Send an email', 'C. Check the inventory', 'D. Update the schedule'], correctAnswer: 3, explanation: 'Người phụ nữ nói "I\'ll update the schedule right away" — cho biết hành động tiếp theo là cập nhật lịch trình.' },
{ id: 'q5', part: 2, text: 'What problem does the man mention?', options: ['A. A delayed shipment', 'B. A broken device', 'C. A missing document', 'D. A scheduling conflict'], correctAnswer: 0, explanation: '"The delivery has been delayed by two days" — vấn đề được đề cập là lô hàng bị trễ.' },
{ id: 'q6', part: 2, text: 'How does the woman respond to the proposal?', options: ['A. She accepts it', 'B. She rejects it', 'C. She needs more time', 'D. She suggests modifications'], correctAnswer: 3, explanation: '"That sounds good, but maybe we could adjust the timeline a bit" — đề xuất điều chỉnh, không chấp nhận hoàn toàn.' },
{ id: 'q7', part: 2, text: 'What is the purpose of the announcement?', options: ['A. To introduce new products', 'B. To notify schedule changes', 'C. To welcome new employees', 'D. To announce a promotion'], correctAnswer: 1, explanation: 'Thông báo nói về việc thay đổi giờ làm việc từ tuần tới — mục đích là thông báo thay đổi lịch.' },
{ id: 'q8', part: 2, text: 'What does the woman ask the man to do?', options: ['A. Prepare a presentation', 'B. Contact the client', 'C. Review the contract', 'D. Attend a training session'], correctAnswer: 2, explanation: '"Could you go over the contract before we sign?" — người phụ nữ yêu cầu xem lại hợp đồng.' },
{ id: 'q9', part: 2, text: 'When will the project be completed?', options: ['A. By the end of this week', 'B. Next Monday', 'C. In two weeks', 'D. Next month'], correctAnswer: 0, explanation: '"We should be finished by Friday" — dự án sẽ hoàn thành vào cuối tuần này.' },
{ id: 'q10', part: 2, text: 'What is being discussed at the meeting?', options: ['A. Budget allocations', 'B. Marketing strategies', 'C. Product launches', 'D. Staff promotions'], correctAnswer: 1, explanation: 'Cuộc họp tập trung vào "the new advertising campaign and social media approach" — chiến lược marketing.' },
]
// ─── [UNUSED] ──────────────────────────────────────────────────────────────────
// Real vocab comes from Supabase via useVocab() in src/hooks/use-vocab.ts
export const VOCAB_DATA: Record<string, VocabWord[]> = {
'Tất cả': [
{ id: 'v1', word: 'negotiate', phonetic: '/nɪˈɡoʊʃieɪt/', meaningVi: 'đàm phán', topic: 'Business', example: 'We need to negotiate the contract terms before signing.' },
{ id: 'v2', word: 'collaborate', phonetic: '/kəˈlæbəreɪt/', meaningVi: 'hợp tác', topic: 'Business', example: 'Teams need to collaborate effectively to meet the deadline.' },
{ id: 'v3', word: 'agenda', phonetic: '/əˈdʒendə/', meaningVi: 'chương trình nghị sự', topic: 'Office', example: 'The agenda has been sent to all meeting participants.' },
{ id: 'v4', word: 'itinerary', phonetic: '/aɪˈtɪnəreri/', meaningVi: 'lịch trình chuyến đi', topic: 'Travel', example: 'Here is your travel itinerary for the business conference.' },
{ id: 'v5', word: 'reimburse', phonetic: '/ˌriːɪmˈːrs/', meaningVi: 'hoàn tiền', topic: 'Finance', example: 'The company will reimburse your travel expenses.' },
{ id: 'v6', word: 'recruit', phonetic: '/rɪˈkruːt/', meaningVi: 'tuyển dụng', topic: 'HR', example: 'We are actively recruiting experienced engineers.' },
{ id: 'v7', word: 'campaign', phonetic: '/kæmˈpeɪn/', meaningVi: 'chiến dịch', topic: 'Marketing', example: 'The marketing campaign was very successful this quarter.' },
{ id: 'v8', word: 'implement', phonetic: '/ˈɪmplɪment/', meaningVi: 'triển khai, thực hiện', topic: 'Business', example: 'We plan to implement the new strategy next quarter.' },
],
'Business': [
{ id: 'b1', word: 'negotiate', phonetic: '/nɪˈɡoʊʃieɪt/', meaningVi: 'đàm phán', topic: 'Business', example: 'We need to negotiate the contract terms.' },
{ id: 'b2', word: 'collaborate', phonetic: '/kəˈlæbəreɪt/', meaningVi: 'hợp tác', topic: 'Business', example: 'Teams collaborate to achieve shared goals.' },
{ id: 'b3', word: 'delegate', phonetic: '/ˈdelɪɡeɪt/', meaningVi: 'uỷ quyền, phân công', topic: 'Business', example: 'A good manager knows how to delegate tasks.' },
{ id: 'b4', word: 'implement', phonetic: '/ˈɪmplɪment/', meaningVi: 'triển khai, thực hiện', topic: 'Business', example: 'We will implement the new policy next month.' },
{ id: 'b5', word: 'merger', phonetic: '/ˈːrdʒər/', meaningVi: 'sáp nhập công ty', topic: 'Business', example: 'The merger will create a stronger combined company.' },
{ id: 'b6', word: 'acquisition', phonetic: '/ˌækwɪˈzɪʃən/', meaningVi: 'mua lại, thâu tóm', topic: 'Business', example: 'The acquisition was completed ahead of schedule.' },
],
'Office': [
{ id: 'o1', word: 'agenda', phonetic: '/əˈdʒendə/', meaningVi: 'chương trình nghị sự', topic: 'Office', example: 'Please review the agenda before the meeting.' },
{ id: 'o2', word: 'minutes', phonetic: '/ˈmɪnɪts/', meaningVi: 'biên bản họp', topic: 'Office', example: 'Could you take the meeting minutes today?' },
{ id: 'o3', word: 'submit', phonetic: '/səbˈmɪt/', meaningVi: 'nộp, gửi đi', topic: 'Office', example: 'Please submit your report by Friday afternoon.' },
{ id: 'o4', word: 'deadline', phonetic: '/ˈdedlaɪn/', meaningVi: 'hạn chót', topic: 'Office', example: 'The deadline for this project is end of month.' },
{ id: 'o5', word: 'cubicle', phonetic: '/ˈkjuːbɪkəl/', meaningVi: 'góc làm việc riêng', topic: 'Office', example: 'Each employee has their own cubicle in the open office.' },
],
'Travel': [
{ id: 't1', word: 'itinerary', phonetic: '/aɪˈtɪnəreri/', meaningVi: 'lịch trình chuyến đi', topic: 'Travel', example: 'Here is your detailed travel itinerary.' },
{ id: 't2', word: 'boarding pass', phonetic: '/ˈːrdɪŋ pæs/', meaningVi: 'thẻ lên máy bay', topic: 'Travel', example: 'Please have your boarding pass ready at the gate.' },
{ id: 't3', word: 'layover', phonetic: '/ˈleɪoʊvər/', meaningVi: 'thời gian quá cảnh', topic: 'Travel', example: 'There is a two-hour layover in Singapore.' },
{ id: 't4', word: 'customs', phonetic: '/ˈkʌstəmz/', meaningVi: 'hải quan', topic: 'Travel', example: 'All passengers must go through customs on arrival.' },
{ id: 't5', word: 'baggage claim', phonetic: '/ˈɡɪdʒ kleɪm/', meaningVi: 'băng chuyền hành lý', topic: 'Travel', example: 'Meet us at the baggage claim after landing.' },
],
'Finance': [
{ id: 'f1', word: 'reimburse', phonetic: '/ˌriːɪmˈːrs/', meaningVi: 'hoàn tiền', topic: 'Finance', example: 'The company will reimburse all travel expenses.' },
{ id: 'f2', word: 'invoice', phonetic: '/ˈɪnvɔɪs/', meaningVi: 'hoá đơn', topic: 'Finance', example: 'Please send the invoice to our accounting department.' },
{ id: 'f3', word: 'budget', phonetic: '/ˈbʌdʒɪt/', meaningVi: 'ngân sách', topic: 'Finance', example: 'We need to stay within the approved budget.' },
{ id: 'f4', word: 'revenue', phonetic: '/ˈrevɪnjuː/', meaningVi: 'doanh thu', topic: 'Finance', example: 'Revenue increased by 15% last quarter.' },
{ id: 'f5', word: 'fiscal year', phonetic: '/ˈfɪskəl jɪər/', meaningVi: 'năm tài chính', topic: 'Finance', example: 'Our fiscal year ends on December 31st.' },
],
'HR': [
{ id: 'h1', word: 'recruit', phonetic: '/rɪˈkruːt/', meaningVi: 'tuyển dụng', topic: 'HR', example: 'We are recruiting experienced software engineers.' },
{ id: 'h2', word: 'probation', phonetic: '/proʊˈbeɪʃən/', meaningVi: 'thử việc', topic: 'HR', example: 'New employees have a 3-month probation period.' },
{ id: 'h3', word: 'appraisal', phonetic: '/əˈpreɪzəl/', meaningVi: 'đánh giá nhân viên', topic: 'HR', example: 'Annual performance appraisals are held in December.' },
{ id: 'h4', word: 'resignation', phonetic: '/ˌrezɪɡˈneɪʃən/', meaningVi: 'đơn từ chức', topic: 'HR', example: 'She submitted her resignation letter this morning.' },
{ id: 'h5', word: 'onboarding', phonetic: '/ˈɒnbɔːrdɪŋ/', meaningVi: 'quy trình tiếp nhận nhân viên mới', topic: 'HR', example: 'The onboarding process takes about two weeks.' },
],
'Marketing': [
{ id: 'm1', word: 'campaign', phonetic: '/kæmˈpeɪn/', meaningVi: 'chiến dịch', topic: 'Marketing', example: 'The marketing campaign exceeded all expectations.' },
{ id: 'm2', word: 'demographics', phonetic: '/ˌdeməˈɡræfɪks/', meaningVi: 'nhân khẩu học', topic: 'Marketing', example: 'We need to understand our target demographics.' },
{ id: 'm3', word: 'endorse', phonetic: '/ɪnˈːrs/', meaningVi: 'chứng thực, bảo trợ', topic: 'Marketing', example: 'The product is endorsed by professional athletes.' },
{ id: 'm4', word: 'branding', phonetic: '/ˈbrændɪŋ/', meaningVi: 'xây dựng thương hiệu', topic: 'Marketing', example: 'Consistent branding builds long-term customer trust.' },
{ id: 'm5', word: 'conversion rate', phonetic: '/kənˈːrʒən reɪt/', meaningVi: 'tỷ lệ chuyển đổi', topic: 'Marketing', example: 'Our conversion rate improved after the redesign.' },
],
}
// ─── [UNUSED] ──────────────────────────────────────────────────────────────────
// Real writing feedback is generated server-side via the GLM Edge Function
// Used only as a visual reference during development
export const MOCK_WRITING_FEEDBACK: WritingFeedback = {
score: '6.5',
grammar: [
'"managers are concern" → nên dùng "concerned" (tính từ, không phải danh từ)',
'Thiếu mạo từ "an" trước "efficient arrangement" ở câu cuối',
'Câu "This change is expected to improve" — đúng nhưng hơi thụ động, có thể dùng active voice',
],
vocabulary: [
'Tốt: "implement", "productivity", "collaboration", "arrangement"',
'Gợi ý nâng cao: "enhance" thay "increase", "address" thay "help with"',
'Nên thêm từ nối: "Nevertheless", "In addition", "As a result of this"',
],
structure: 'Bài viết có cấu trúc khá rõ ràng với mở đầu, thân bài và kết luận ngầm. Tuy nhiên cần phát triển thêm phần giải thích tác động và thêm ví dụ cụ thể để bài hoàn chỉnh hơn.',
improvedVersion: 'The company has decided to implement a new remote work policy starting next month. All employees will be able to work from home for three days per week. This change is expected to enhance work-life balance and boost overall productivity. Nevertheless, some managers are concerned about communication challenges and team collaboration. To address these concerns, the HR department will organize training sessions to help teams adapt to this new arrangement effectively.',
summary: 'Bài viết đạt mức Upper Intermediate (6.5) với ý tưởng rõ ràng. Cần sửa lỗi ngữ pháp cơ bản và bổ sung từ vựng phong phú hơn để đạt band 7.0+.',
}
// ─── [SUPERSEDED] ─────────────────────────────────────────────────────────────
// Was: src/pages/dashboard/gamification-store.ts
// Replaced by: src/lib/gamification-service.ts + src/hooks/use-gamification.ts (Supabase)
// Kept here for reference — DO NOT import in production code
export interface _LegacyGamificationState {
xu: number; streak: number; xp: number; xpNextLevel: number
level: number; levelName: string; weeklyCompleted: number
weeklyGoal: number; weekActivity: boolean[]
}

View File

@@ -1,10 +1,40 @@
export interface Question {
id: string
part: number
text: string
options: string[]
correctAnswer: number // 0-3
explanation: string
id: number // SERIAL from question table
partNumber: number // from part.part_number — needed for session grouping
text: string | null // question_text (null for photo/audio-only questions)
options: string[] // answer_choice.label_text ordered A→D
correctAnswer: number // 0-3 derived from answer_choice.is_correct
explanation: string | null
groupId: number
audioUrl?: string // from question_group
imageUrl?: string // from question_group
passageText?: string // from question_group (Part 6/7)
}
// One part's worth of questions inside a test session
export interface SessionPart {
partNumber: number
partName: string // e.g. "Mô tả hình ảnh"
questions: Question[]
}
// A test record from the test table
export interface TestRecord {
id: number
title: string
description: string | null
totalQuestions: number
durationMinutes: number
categoryName: string | null
}
// A part record from the part table
export interface PartRecord {
id: number
testId: number
partNumber: number
title: string
questionCount: number
}
export interface VocabWord {
@@ -44,6 +74,13 @@ export interface WritingFeedback {
summary: string
}
export interface WritingSubmission {
id: string
content: string
feedback: WritingFeedback
created_at: string
}
export interface ToeicPart {
id: number
name: string
@@ -58,3 +95,45 @@ export interface User {
email: string
name: string
}
// Phase 3 — Gamification
export type UserLevel = 'beginner' | 'bronze' | 'silver' | 'gold' | 'master'
export type XuTransactionType =
| 'earn_welcome'
| 'earn_daily'
| 'earn_streak'
| 'earn_ads'
| 'spend_freeze'
| 'spend_writing'
| 'spend_test'
export interface UserGamification {
userId: string
xp: number
level: UserLevel
streak: number
longestStreak: number
lastActive: string | null // DATE as ISO string
xu: number
freezeCount: number
createdAt: string
}
export interface XuTransaction {
id: string
userId: string
type: XuTransactionType
amount: number
balance: number
description: string | null
createdAt: string
}
export interface WeeklyLeaderboardEntry {
id: string
userId: string
weekStart: string
xpEarned: number
rank: number | null
}

View File

@@ -0,0 +1,68 @@
# Design System
Auto-generated from Google Stitch export.
## Colors
- `border-r-0`
- `bg-slate-50`
- `bg-slate-900`
- `text-blue-600`
- `text-blue-400`
- `bg-slate-100`
- `bg-slate-800`
- `text-slate-500`
- `text-blue-700`
- `text-blue-300`
- `bg-blue-50`
- `bg-blue-900`
- `text-slate-400`
- `text-slate-600`
- `bg-slate-300`
## Typography
- `text-xl`
- `font-bold`
- `font-medium`
- `text-sm`
- `text-xs`
- `font-semibold`
- `text-base`
- `text-4xl`
- `font-extrabold`
- `text-3xl`
- `text-lg`
- `text-2xl`
## Spacing
- `p-0`
- `p-6`
- `gap-3`
- `p-3`
- `gap-2`
- `gap-6`
- `gap-4`
- `p-2`
- `gap-1`
- `p-8`
- `p-4`
- `gap-8`
- `space-y-6`
- `space-y-3`
- `m-8`
## Components
- `<nav>`
- `<button>`
- `<header>`
- `<main>`
- `<table>`
## Notes
- Generated by Google Stitch AI
- Tailwind CSS utility classes used throughout
- Review and customize colors/typography for brand alignment

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -0,0 +1,419 @@
<!DOCTYPE html>
<html lang="vi"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>TOEIC Master - Achievement Hub</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"surface-container": "#eceef0",
"background": "#f7f9fb",
"on-surface": "#191c1e",
"secondary-fixed": "#7ffc97",
"surface-tint": "#0053db",
"on-primary-fixed": "#00174b",
"primary-fixed": "#dbe1ff",
"tertiary": "#784b00",
"tertiary-fixed": "#ffddb8",
"on-primary-fixed-variant": "#003ea8",
"secondary-container": "#7cf994",
"surface-container-highest": "#e0e3e5",
"surface-container-lowest": "#ffffff",
"on-error-container": "#93000a",
"on-secondary-container": "#007230",
"inverse-surface": "#2d3133",
"on-secondary": "#ffffff",
"on-error": "#ffffff",
"on-background": "#191c1e",
"on-primary-container": "#eeefff",
"primary": "#004ac6",
"error-container": "#ffdad6",
"surface-container-high": "#e6e8ea",
"tertiary-container": "#996100",
"on-tertiary": "#ffffff",
"on-surface-variant": "#434655",
"tertiary-fixed-dim": "#ffb95f",
"primary-fixed-dim": "#b4c5ff",
"surface-variant": "#e0e3e5",
"on-secondary-fixed": "#002109",
"on-primary": "#ffffff",
"surface-container-low": "#f2f4f6",
"inverse-on-surface": "#eff1f3",
"secondary": "#006e2d",
"on-tertiary-fixed": "#2a1700",
"outline-variant": "#c3c6d7",
"error": "#ba1a1a",
"secondary-fixed-dim": "#62df7d",
"surface": "#f7f9fb",
"on-tertiary-fixed-variant": "#653e00",
"outline": "#737686",
"surface-dim": "#d8dadc",
"on-tertiary-container": "#ffeedd",
"primary-container": "#2563eb",
"on-secondary-fixed-variant": "#005320",
"inverse-primary": "#b4c5ff",
"surface-bright": "#f7f9fb"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
"fontFamily": {
"headline": ["Plus Jakarta Sans"],
"body": ["Plus Jakarta Sans"],
"label": ["Plus Jakarta Sans"]
}
},
},
}
</script>
<style>
body { font-family: 'Plus Jakarta Sans', sans-serif; background-color: #f7f9fb; color: #191c1e; }
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; }
.achievement-shadow { box-shadow: 0 12px 32px rgba(0, 74, 198, 0.06); }
.glass-streak { background: rgba(255, 221, 184, 0.4); backdrop-filter: blur(8px); }
.no-scrollbar::-webkit-scrollbar { display: none; }
</style>
</head>
<body class="antialiased">
<!-- SideNavBar (Execution from JSON) -->
<nav class="fixed left-0 top-0 h-full p-6 flex flex-col h-screen w-64 border-r-0 bg-slate-50 dark:bg-slate-900 z-50">
<div class="text-xl font-bold text-blue-600 dark:text-blue-400 mb-8">TOEIC Master</div>
<div class="flex items-center gap-3 mb-8 p-3 bg-slate-100 dark:bg-slate-800 rounded-xl">
<img alt="Student Profile Picture" class="w-10 h-10 rounded-full object-cover" data-alt="Close-up portrait of a smiling young man in a professional setting with soft studio lighting" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBi8VjVfuHDcoJwcD6GgNyZPsVmGg0H2GZFuNy-cQMzCBmk4-Hf6GjrULENRUCVom73DjyCDJNjMIWzu1OKexmQbh294_VG3PTir4_qOTDvHskTGf0q46xgWJhF73M3lrUBdmz4jNyFidkkCg5BL3D0JRxq68NF2qaK3UYGcW4S3rznkE04vQre6ofw2dRLEVvdCxcAH9GVUzS9oVSUQ96zwprfK6594JP0zsAABWKBQXB9McF1J3_s9KHJyVsnurq2_DVTMlrAM_L1"/>
<div>
<p class="font-['Plus_Jakarta_Sans'] font-medium text-sm tracking-tight text-on-surface">Achievement Hub</p>
<p class="text-xs text-slate-500">Level 14 Explorer</p>
</div>
</div>
<div class="flex flex-col gap-2 flex-grow">
<!-- Home (Active) -->
<a class="flex items-center gap-3 px-4 py-3 text-blue-700 dark:text-blue-300 font-bold bg-blue-50 dark:bg-blue-900/30 rounded-lg scale-95 duration-150 active:opacity-80 transition-colors" href="#">
<span class="material-symbols-outlined" data-icon="home">home</span>
<span class="font-['Plus_Jakarta_Sans'] font-medium text-sm tracking-tight">Home</span>
</a>
<!-- Practice -->
<a class="flex items-center gap-3 px-4 py-3 text-slate-500 dark:text-slate-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors scale-95 duration-150 active:opacity-80" href="#">
<span class="material-symbols-outlined" data-icon="exercise">exercise</span>
<span class="font-['Plus_Jakarta_Sans'] font-medium text-sm tracking-tight">Practice</span>
</a>
<!-- Leaderboard -->
<a class="flex items-center gap-3 px-4 py-3 text-slate-500 dark:text-slate-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors scale-95 duration-150 active:opacity-80" href="#">
<span class="material-symbols-outlined" data-icon="leaderboard">leaderboard</span>
<span class="font-['Plus_Jakarta_Sans'] font-medium text-sm tracking-tight">Leaderboard</span>
</a>
<!-- Shop -->
<a class="flex items-center gap-3 px-4 py-3 text-slate-500 dark:text-slate-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors scale-95 duration-150 active:opacity-80" href="#">
<span class="material-symbols-outlined" data-icon="shopping_cart">shopping_cart</span>
<span class="font-['Plus_Jakarta_Sans'] font-medium text-sm tracking-tight">Shop</span>
</a>
<!-- Settings -->
<a class="flex items-center gap-3 px-4 py-3 text-slate-500 dark:text-slate-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors scale-95 duration-150 active:opacity-80" href="#">
<span class="material-symbols-outlined" data-icon="settings">settings</span>
<span class="font-['Plus_Jakarta_Sans'] font-medium text-sm tracking-tight">Settings</span>
</a>
</div>
<button class="mt-auto w-full py-3 bg-primary text-on-primary rounded-xl font-semibold text-sm transition-transform duration-200 active:scale-95 shadow-lg shadow-primary/20">
Upgrade to Pro
</button>
</nav>
<!-- TopAppBar (Execution from JSON) -->
<header class="fixed top-0 right-0 h-16 flex items-center justify-between px-8 py-4 ml-64 w-[calc(100%-16rem)] bg-transparent z-40">
<div class="flex items-center gap-2 text-slate-500 font-['Plus_Jakarta_Sans'] font-semibold text-base">
<span>Trang chủ</span>
<span class="material-symbols-outlined text-sm">chevron_right</span>
<span class="text-on-surface">Bảng điều khiển</span>
</div>
<div class="flex items-center gap-6">
<div class="flex items-center gap-4">
<button class="hover:bg-slate-100 dark:hover:bg-slate-800 rounded-full p-2 transition-transform duration-200 active:scale-90 text-slate-600">
<span class="material-symbols-outlined" data-icon="notifications">notifications</span>
</button>
<div class="flex items-center gap-1 px-3 py-1 bg-tertiary-container/10 rounded-full border border-tertiary-container/20">
<span class="material-symbols-outlined text-tertiary font-variation-settings: 'FILL' 1;" data-icon="local_fire_department" style="font-variation-settings: 'FILL' 1;">local_fire_department</span>
<span class="text-sm font-bold text-tertiary">14 Days</span>
</div>
<div class="flex items-center gap-1 px-3 py-1 bg-secondary-container/10 rounded-full border border-secondary-container/20">
<span class="material-symbols-outlined text-secondary" data-icon="monetization_on" style="font-variation-settings: 'FILL' 1;">monetization_on</span>
<span class="text-sm font-bold text-secondary">2,450 Xu</span>
</div>
</div>
<div class="w-8 h-8 rounded-full bg-surface-container overflow-hidden">
<img alt="User Avatar" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCXJgxIEysUKtL0g8IDfmqULe-aiYg1DAmaCwbXsw_bu3UclhqlR8RhcnEsKTzs-_poHPQTsIFCcQZZiCxS7NkzMI7KUqRddmPF_MMXuDphyztHqi5QwrrvhdTDSUo49fSB06qpyLU07s4pR5aMp_AJqeqV0Nr4WraPTluc9FvXO7tvPPJQIsr6XtzGX7Gm82iOhZSX6TqYcX_P6liiQXdbEMAJdQ99PSS0s40KtQ2Ok8rh5l_2jMlWlqxiZuIBRbJlIqXTMSjrxsxG"/>
</div>
</div>
</header>
<!-- Main Content Canvas -->
<main class="ml-64 pt-20 p-8 min-h-screen">
<!-- Hero Stats Row (Bento Style) -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Xu Balance -->
<div class="relative overflow-hidden bg-surface-container-lowest p-6 rounded-xl achievement-shadow group">
<div class="absolute -right-4 -top-4 w-24 h-24 bg-tertiary-fixed rounded-full opacity-20 blur-2xl group-hover:opacity-40 transition-opacity"></div>
<div class="flex flex-col">
<span class="label-sm uppercase tracking-widest text-outline font-bold mb-1">Số dư Xu</span>
<div class="flex items-center gap-3">
<span class="text-4xl font-extrabold text-on-surface">2,450</span>
<span class="material-symbols-outlined text-4xl text-tertiary-fixed-dim" style="font-variation-settings: 'FILL' 1;">monetization_on</span>
</div>
<p class="text-sm text-outline-variant mt-2 font-medium">Tương đương 245.000 VNĐ</p>
</div>
</div>
<!-- Streak Card -->
<div class="relative overflow-hidden bg-primary p-6 rounded-xl achievement-shadow">
<div class="absolute inset-0 bg-gradient-to-br from-primary-container to-primary opacity-90"></div>
<div class="relative z-10 flex flex-col text-on-primary">
<span class="label-sm uppercase tracking-widest opacity-80 font-bold mb-1">Chuỗi học tập</span>
<div class="flex items-center gap-3">
<span class="text-4xl font-extrabold">14 Ngày</span>
<span class="material-symbols-outlined text-4xl text-tertiary-fixed" style="font-variation-settings: 'FILL' 1;">local_fire_department</span>
</div>
<p class="text-sm opacity-90 mt-2 font-medium">Bạn thuộc top 5% người học chăm chỉ!</p>
</div>
</div>
<!-- XP / Level Card -->
<div class="bg-surface-container-lowest p-6 rounded-xl achievement-shadow flex items-center justify-between">
<div>
<span class="label-sm uppercase tracking-widest text-outline font-bold mb-1">Cấp độ hiện tại</span>
<div class="flex items-center gap-2">
<span class="text-4xl font-extrabold text-on-surface">Level 14</span>
</div>
<span class="inline-block mt-2 px-3 py-1 bg-tertiary-container/10 text-tertiary text-xs font-bold rounded-full border border-tertiary-container/20">Hạng Đồng (Bronze)</span>
</div>
<div class="w-16 h-16 bg-surface-container flex items-center justify-center rounded-2xl rotate-12">
<span class="material-symbols-outlined text-primary text-3xl font-bold">military_tech</span>
</div>
</div>
</div>
<!-- Progress Section (Asymmetric Grid) -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-8">
<!-- Circular XP Progress -->
<div class="lg:col-span-5 bg-surface-container-lowest p-8 rounded-xl achievement-shadow flex flex-col items-center justify-center text-center">
<h3 class="text-lg font-bold mb-6 self-start">Tiến độ Cấp độ</h3>
<div class="relative w-48 h-48 mb-6">
<!-- Background Circle -->
<svg class="w-full h-full transform -rotate-90">
<circle class="text-surface-container" cx="96" cy="96" fill="transparent" r="88" stroke="currentColor" stroke-width="12"></circle>
<circle class="text-primary rounded-full" cx="96" cy="96" fill="transparent" r="88" stroke="currentColor" stroke-dasharray="552" stroke-dashoffset="138" stroke-width="12"></circle>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="text-3xl font-extrabold">75%</span>
<span class="text-xs text-outline font-bold">1,200 / 1,600 XP</span>
</div>
</div>
<p class="text-sm text-on-surface-variant font-medium">Chỉ còn 400 XP nữa để đạt Level 15!</p>
<button class="mt-6 w-full py-3 bg-surface-container-high hover:bg-surface-container-highest transition-colors rounded-xl font-bold text-sm text-primary">
Xem nhiệm vụ XP
</button>
</div>
<!-- Weekly Goal & Heatmap -->
<div class="lg:col-span-7 space-y-6">
<!-- Weekly Goal -->
<div class="bg-surface-container-lowest p-6 rounded-xl achievement-shadow">
<div class="flex justify-between items-end mb-4">
<div>
<h3 class="text-lg font-bold">Mục tiêu tuần</h3>
<p class="text-sm text-outline">Hoàn thành 5 bài học mỗi tuần</p>
</div>
<span class="text-2xl font-black text-secondary">3/5</span>
</div>
<div class="w-full h-3 bg-surface-container rounded-full overflow-hidden">
<div class="h-full bg-secondary-container rounded-full" style="width: 60%"></div>
</div>
</div>
<!-- Streak Heatmap -->
<div class="bg-surface-container-lowest p-6 rounded-xl achievement-shadow">
<h3 class="text-lg font-bold mb-4">Lịch sử rèn luyện</h3>
<div class="flex justify-between items-center px-2">
<!-- Day Columns -->
<div class="flex flex-col items-center gap-3">
<span class="text-[10px] font-bold text-outline uppercase">Th 2</span>
<div class="w-10 h-10 rounded-lg bg-secondary-fixed flex items-center justify-center">
<span class="material-symbols-outlined text-on-secondary-fixed text-sm">check</span>
</div>
</div>
<div class="flex flex-col items-center gap-3">
<span class="text-[10px] font-bold text-outline uppercase">Th 3</span>
<div class="w-10 h-10 rounded-lg bg-secondary-fixed flex items-center justify-center">
<span class="material-symbols-outlined text-on-secondary-fixed text-sm">check</span>
</div>
</div>
<div class="flex flex-col items-center gap-3">
<span class="text-[10px] font-bold text-outline uppercase">Th 4</span>
<div class="w-10 h-10 rounded-lg bg-secondary-fixed flex items-center justify-center">
<span class="material-symbols-outlined text-on-secondary-fixed text-sm">check</span>
</div>
</div>
<div class="flex flex-col items-center gap-3">
<span class="text-[10px] font-bold text-outline uppercase">Th 5</span>
<div class="w-10 h-10 rounded-lg bg-secondary-fixed flex items-center justify-center">
<span class="material-symbols-outlined text-on-secondary-fixed text-sm">check</span>
</div>
</div>
<div class="flex flex-col items-center gap-3">
<span class="text-[10px] font-bold text-primary uppercase">H.Nay</span>
<div class="w-10 h-10 rounded-lg border-2 border-primary border-dashed flex items-center justify-center">
<span class="material-symbols-outlined text-primary text-sm">play_arrow</span>
</div>
</div>
<div class="flex flex-col items-center gap-3 opacity-30">
<span class="text-[10px] font-bold text-outline uppercase">Th 7</span>
<div class="w-10 h-10 rounded-lg bg-surface-container flex items-center justify-center"></div>
</div>
<div class="flex flex-col items-center gap-3 opacity-30">
<span class="text-[10px] font-bold text-outline uppercase">CN</span>
<div class="w-10 h-10 rounded-lg bg-surface-container flex items-center justify-center"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Xu Economy & Leaderboard Row -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- Xu Economy Card -->
<div class="lg:col-span-4 bg-surface-container-lowest p-6 rounded-xl achievement-shadow overflow-hidden relative">
<div class="relative z-10">
<h3 class="text-lg font-bold mb-6">Cửa hàng Xu</h3>
<div class="space-y-6">
<div>
<span class="label-sm text-secondary font-bold uppercase tracking-wider block mb-3">Kiếm Xu</span>
<div class="space-y-3">
<div class="flex items-center justify-between p-3 bg-surface-container-low rounded-lg">
<span class="text-sm font-medium">Mục tiêu ngày</span>
<span class="text-sm font-bold text-tertiary">+10 xu</span>
</div>
<div class="flex items-center justify-between p-3 bg-surface-container-low rounded-lg">
<span class="text-sm font-medium">Mốc chuỗi (Streak)</span>
<span class="text-sm font-bold text-tertiary">+20 xu</span>
</div>
<div class="flex items-center justify-between p-3 bg-surface-container-low rounded-lg">
<span class="text-sm font-medium">Xem quảng cáo</span>
<span class="text-sm font-bold text-tertiary">+5 xu</span>
</div>
</div>
</div>
<div>
<span class="label-sm text-error font-bold uppercase tracking-wider block mb-3">Tiêu Xu</span>
<div class="space-y-3">
<div class="flex items-center justify-between p-3 bg-surface-container-low rounded-lg opacity-80">
<span class="text-sm font-medium">Streak Freeze</span>
<span class="text-sm font-bold text-on-surface-variant">20 xu</span>
</div>
<div class="flex items-center justify-between p-3 bg-surface-container-low rounded-lg opacity-80">
<span class="text-sm font-medium">AI Writing Feedback</span>
<span class="text-sm font-bold text-on-surface-variant">30 xu</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Weekly Leaderboard -->
<div class="lg:col-span-8 bg-surface-container-lowest p-6 rounded-xl achievement-shadow">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold">Bảng xếp hạng tuần</h3>
<div class="flex gap-2">
<button class="px-3 py-1 bg-primary-container text-on-primary-container rounded-full text-xs font-bold">Top 100</button>
<button class="px-3 py-1 bg-surface-container-high text-on-surface-variant rounded-full text-xs font-bold">Bạn bè</button>
</div>
</div>
<div class="overflow-hidden">
<table class="w-full text-left border-separate border-spacing-y-2">
<thead>
<tr class="text-outline text-[10px] font-bold uppercase tracking-widest">
<th class="pb-2 pl-4">Hạng</th>
<th class="pb-2">Người học</th>
<th class="pb-2 text-right pr-4">XP Tổng</th>
</tr>
</thead>
<tbody>
<!-- Rank 1 -->
<tr class="bg-surface-container-low/50 hover:bg-surface-container-low transition-colors group">
<td class="py-3 pl-4 rounded-l-xl w-16">
<div class="w-8 h-8 flex items-center justify-center bg-tertiary-fixed-dim text-on-tertiary-fixed font-bold rounded-full text-xs">1</div>
</td>
<td class="py-3">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-slate-300">
<img alt="Leaderboard User" class="w-full h-full rounded-full object-cover" data-alt="Portrait of a young professional woman in a bright office environment, smiling confidently" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDvzxcWKPKaoweBqyoXTb89mtDdHNSiQOPT-MuPtbTIPSgKseQr7BMjfk2Q_ouJow_nGLtEtZmGpYVxXPYGv7RglZbbpRZsVXAZcd6HAROWiktTi2pydQ-HX_GA1uXlNkXwLOgKLDsDDuEVfjm_BWTrsQh99ztiz_RYwtF7EYwJI-NAC3kwMN8w1BQ2VVmxse900xV6WZEoUHDOm1PE4mMFTMWgqKtQVzzMtU70bglHOB3br2nojBHXW2cUSOpnDGLVgynSGc3R0bUm"/>
</div>
<span class="text-sm font-bold">Minh Anh</span>
</div>
</td>
<td class="py-3 pr-4 text-right rounded-r-xl">
<span class="text-sm font-bold text-primary">12,450 XP</span>
</td>
</tr>
<!-- Rank 2 -->
<tr class="bg-surface-container-low/50 hover:bg-surface-container-low transition-colors">
<td class="py-3 pl-4 rounded-l-xl">
<div class="w-8 h-8 flex items-center justify-center bg-surface-container-high text-on-surface font-bold rounded-full text-xs">2</div>
</td>
<td class="py-3">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-slate-300 overflow-hidden">
<img alt="Leaderboard User" class="w-full h-full object-cover" data-alt="Smiling man in casual attire, outdoor urban setting with soft bokeh background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuA74HK_XasMK05dy3-PV9MrXZjAMDIQy2YVA_lNOZH35kWKPfvUDTR3CoiWXFRaC5Kv2R19s1JCkyVKfoZWkkTIDpBR0tMNdqe5tZ6dbTz5qAjfd2fqRJRluFqOLKdauKd3nV5u0pDP7hawaTt6qYut_sSjgVzVxb_ue9e7rsUorbd2MwhYVisDVe2OxqbpJEWxEprsvD1Hq1Cyynl5bXOp3MRRndelJuRzs7udHOEr9gf56NyaG4XHv8JzoBaP23YaK-CUxp_-_VE0"/>
</div>
<span class="text-sm font-bold">Hoàng Nam</span>
</div>
</td>
<td class="py-3 pr-4 text-right rounded-r-xl">
<span class="text-sm font-bold text-primary">11,200 XP</span>
</td>
</tr>
<!-- Rank Current User -->
<tr class="bg-primary-container/10 border-2 border-primary/20 rounded-xl">
<td class="py-3 pl-4 rounded-l-xl">
<div class="w-8 h-8 flex items-center justify-center bg-primary text-on-primary font-bold rounded-full text-xs shadow-lg shadow-primary/30">7</div>
</td>
<td class="py-3">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-primary-fixed overflow-hidden ring-2 ring-primary ring-offset-2">
<img alt="Leaderboard User" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAh40p1R5HEI8qMmXljq9v2-C2lksWh9bg4r-DfQ1Udmt8YkUyW2gzEsomPu94XJB9lxoa1JLqaacdoNFNqVkjsxlRcuGrw9mXqiS57fA64j7Jx7UkQ0OcuQAA2eHtkTuDcvUzeTFVHt5lUQfAVDXpy7hUV2-YCFGuNkc0jvaQxb3vyRsea49hqn9NznDGBNC_Nn4dkNQL0hZC4wlRLhxEfCTKKQX6giA2AAPtSmCzqXiXofoZ67mV5KlZmbVh5u9doZCR2oM30w3uS"/>
</div>
<span class="text-sm font-extrabold text-primary">Bạn (Achievement Hub)</span>
</div>
</td>
<td class="py-3 pr-4 text-right rounded-r-xl">
<span class="text-sm font-black text-primary">8,950 XP</span>
</td>
</tr>
<!-- Rank 8 -->
<tr class="bg-surface-container-low/50 hover:bg-surface-container-low transition-colors">
<td class="py-3 pl-4 rounded-l-xl">
<div class="w-8 h-8 flex items-center justify-center bg-surface-container-high text-on-surface font-bold rounded-full text-xs">8</div>
</td>
<td class="py-3">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-slate-300 overflow-hidden">
<img alt="Leaderboard User" class="w-full h-full object-cover" data-alt="Confident woman smiling against a simple neutral background, professional headshot style" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDPTY5RBhm4hBjD9tmqpg6EE8QznVn6ig_XSBySi14MAcljPurw9bOALQ4Y96yGfQmzrg0lC6bo-QH-b5BIp5xwF2A94l8DzdLGKG60pSnuHWg3Lse8ugw_0XTT6LQIfppRwnnnaYSzP41qQl8UUBf8ZaD3AKA6DhboSTEJJI3g7EaxanT3BoIrTQ_cy3_MzuYj44fSeMWZUnJ73abeV-w9iuGDtZfbAUNgtx2-3zw8eowW1Y_WA4vrMn6gMXCULTdNXj7oyTsbFJ71"/>
</div>
<span class="text-sm font-bold">Thùy Linh</span>
</div>
</td>
<td class="py-3 pr-4 text-right rounded-r-xl">
<span class="text-sm font-bold text-primary">8,800 XP</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</main>
<!-- FAB Action (Contextual) -->
<button class="fixed bottom-8 right-8 w-16 h-16 bg-primary text-on-primary rounded-2xl flex items-center justify-center shadow-2xl hover:scale-110 active:scale-95 transition-all group z-50">
<span class="material-symbols-outlined text-3xl">play_arrow</span>
<span class="absolute right-full mr-4 bg-inverse-surface text-inverse-on-surface px-4 py-2 rounded-lg text-sm font-bold whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">Học ngay</span>
</button>
</body></html>

View File

@@ -0,0 +1,71 @@
# Design System
Auto-generated from Google Stitch export.
## Colors
- `border-r-0`
- `bg-slate-50`
- `bg-slate-900`
- `text-blue-700`
- `text-blue-500`
- `text-slate-500`
- `text-slate-400`
- `text-blue-600`
- `text-blue-300`
- `bg-slate-100`
- `bg-slate-800`
- `text-blue-400`
- `border-r-4`
- `border-blue-700`
- `border-blue-400`
- `bg-blue-50`
- `bg-blue-900`
- `bg-blue-100`
- `bg-slate-950`
## Typography
- `text-sm`
- `font-medium`
- `text-xl`
- `font-bold`
- `text-xs`
- `text-lg`
- `font-semibold`
- `font-extrabold`
- `text-4xl`
## Spacing
- `p-0`
- `gap-3`
- `space-y-1`
- `p-4`
- `gap-6`
- `gap-2`
- `gap-8`
- `p-8`
- `m-0`
- `p-1`
- `space-y-4`
- `space-y-3`
- `p-3`
- `m-8`
- `gap-4`
## Components
- `<aside>`
- `<nav>`
- `<header>`
- `<main>`
- `<section>`
- `<button>`
- `<input>`
## Notes
- Generated by Google Stitch AI
- Tailwind CSS utility classes used throughout
- Review and customize colors/typography for brand alignment

View File

@@ -0,0 +1,346 @@
<!DOCTYPE html>
<html lang="vi"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Cài đặt - TOEIC Master</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"error": "#ba1a1a",
"primary-container": "#2563eb",
"surface-container-lowest": "#ffffff",
"surface": "#f7f9fb",
"inverse-on-surface": "#eff1f3",
"secondary-fixed": "#dbe1ff",
"primary-fixed": "#dbe1ff",
"surface-container": "#eceef0",
"on-error-container": "#93000a",
"secondary": "#495c95",
"tertiary-container": "#bc4800",
"surface-bright": "#f7f9fb",
"on-primary-fixed-variant": "#003ea8",
"on-background": "#191c1e",
"on-surface": "#191c1e",
"outline": "#737686",
"tertiary": "#943700",
"on-secondary-fixed-variant": "#31447b",
"inverse-primary": "#b4c5ff",
"on-secondary-fixed": "#00174b",
"outline-variant": "#c3c6d7",
"on-tertiary-container": "#ffede6",
"surface-dim": "#d8dadc",
"error-container": "#ffdad6",
"on-error": "#ffffff",
"primary": "#004ac6",
"on-tertiary-fixed": "#360f00",
"on-primary-container": "#eeefff",
"primary-fixed-dim": "#b4c5ff",
"surface-container-highest": "#e0e3e5",
"on-secondary-container": "#394c84",
"tertiary-fixed": "#ffdbcd",
"surface-container-high": "#e6e8ea",
"surface-container-low": "#f2f4f6",
"inverse-surface": "#2d3133",
"background": "#f7f9fb",
"on-surface-variant": "#434655",
"on-primary-fixed": "#00174b",
"tertiary-fixed-dim": "#ffb596",
"on-primary": "#ffffff",
"surface-tint": "#0053db",
"on-secondary": "#ffffff",
"on-tertiary-fixed-variant": "#7d2d00"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
"fontFamily": {
"headline": ["Plus Jakarta Sans"],
"body": ["Plus Jakarta Sans"],
"label": ["Plus Jakarta Sans"]
}
},
},
}
</script>
<style>
body { font-family: 'Plus Jakarta Sans', sans-serif; }
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #eceef0; border-radius: 10px; }
</style>
</head>
<body class="bg-surface text-on-surface">
<!-- SideNavBar Component -->
<aside class="h-screen w-64 fixed left-0 top-0 border-r-0 bg-slate-50 dark:bg-slate-900 flex flex-col py-8 px-4 font-plus-jakarta text-sm font-medium leading-relaxed">
<div class="text-xl font-bold tracking-tight text-blue-700 dark:text-blue-500 mb-8 flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-primary flex items-center justify-center">
<span class="material-symbols-outlined text-white" style="font-variation-settings: 'FILL' 1;">auto_stories</span>
</div>
<div>
<div class="text-blue-700 dark:text-blue-500">TOEIC Master</div>
<div class="text-[10px] uppercase tracking-widest opacity-60">Academic Curator</div>
</div>
</div>
<nav class="space-y-1 flex-1">
<a class="flex items-center px-4 py-3 text-slate-500 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-slate-100 dark:hover:bg-slate-800/80 transition-colors duration-200 rounded-xl group" href="#">
<span class="material-symbols-outlined mr-3">dashboard</span>
Dashboard
</a>
<a class="flex items-center px-4 py-3 text-slate-500 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-slate-100 dark:hover:bg-slate-800/80 transition-colors duration-200 rounded-xl group" href="#">
<span class="material-symbols-outlined mr-3">menu_book</span>
Practice
</a>
<a class="flex items-center px-4 py-3 text-slate-500 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-slate-100 dark:hover:bg-slate-800/80 transition-colors duration-200 rounded-xl group" href="#">
<span class="material-symbols-outlined mr-3">auto_fix_high</span>
AI Writing
</a>
<a class="flex items-center px-4 py-3 text-slate-500 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-slate-100 dark:hover:bg-slate-800/80 transition-colors duration-200 rounded-xl group" href="#">
<span class="material-symbols-outlined mr-3">import_contacts</span>
Vocabulary
</a>
<a class="flex items-center px-4 py-3 text-blue-700 dark:text-blue-400 font-bold border-r-4 border-blue-700 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/20 rounded-xl transition-all" href="#">
<span class="material-symbols-outlined mr-3">settings</span>
Settings
</a>
</nav>
<div class="mt-auto p-4 bg-slate-100 dark:bg-slate-800/50 rounded-2xl">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center overflow-hidden">
<img alt="Avatar" data-alt="close-up portrait of a young professional Vietnamese woman with a friendly smile, soft natural lighting in a modern office environment" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCa5CIDCl8P4XYqgSiIwCTW-Az7PCRUxhInqIN9gmDF_UlfogBPNO5n2Hqki15r4GZmhpJQefbwiKuGwtb2PVbcqU4APFOSk4tMQjZJMJxFngNjfVPg-jcWbQOFXmKJDAMWM9G-7k8Vy3n8r3QGkqZDeWlE36w7ENzdQAePgPZKJBwur1Tjhsp6mn76011vhra_pjwRRBWYNXct9WJ1TjV8MOqy6WxOS8cA4jtve95DaUbo1g9ePzZvWK91bQx_HmViGoZ_nPRm46Y9"/>
</div>
<div>
<p class="font-bold text-on-surface">Minh Anh</p>
<p class="text-xs text-on-surface-variant">Premium Member</p>
</div>
</div>
</div>
</aside>
<!-- TopNavBar Component -->
<header class="fixed top-0 right-0 w-[calc(100%-16rem)] h-16 z-40 bg-white/80 dark:bg-slate-950/80 backdrop-blur-xl flex justify-between items-center px-8 font-plus-jakarta text-lg font-semibold">
<h1 class="text-blue-700 dark:text-blue-500 font-extrabold tracking-tight">Settings</h1>
<div class="flex items-center gap-6">
<span class="material-symbols-outlined text-slate-500 hover:text-blue-600 transition-colors cursor-pointer">notifications</span>
<div class="flex items-center gap-2 cursor-pointer active:scale-95 transition-transform">
<span class="material-symbols-outlined text-slate-500">account_circle</span>
<span class="text-sm font-medium text-slate-500">Tài khoản</span>
</div>
</div>
</header>
<!-- Main Content Canvas -->
<main class="ml-64 pt-24 pb-12 px-8 max-w-5xl">
<div class="grid grid-cols-12 gap-8">
<!-- Section 1: Profile -->
<section class="col-span-12 md:col-span-8 bg-surface-container-lowest rounded-xl p-8 transition-all hover:shadow-xl hover:shadow-primary/5">
<h2 class="text-xl font-bold mb-6 flex items-center gap-2">
<span class="material-symbols-outlined text-primary">person</span>
Hồ sơ cá nhân
</h2>
<div class="flex items-center gap-8">
<div class="relative group">
<div class="w-24 h-24 rounded-full border-4 border-surface overflow-hidden bg-surface-container">
<img alt="User Profile Avatar" class="w-full h-full object-cover" data-alt="A profile photo of a young Vietnamese woman, modern minimalist style with a soft blue studio background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCB6W51bkNQXlN33mlyR1pzHZgkyH8qaXuJd3mqmMDgtRnutAK0ZxKjUns_aOtqwxU6tBxHCF9SBH5SsyLnsXFizb3hJyEM7m5SL6MfcEMMOlmzAuAE1TZuMxreZCI20ix4b107R7z0vqsnP-x8I_ErMuRlrhEEvwlBXWrpyTKdgcrxf0iZj18uBxN5qTD3wcyEp0At8Zv6EYVb42YguIF1jg4_K9lc0klwTnTz1NvZk1wSTqwVpErnRIs08sGT1TaXfnOLh30HqGdm"/>
</div>
<button class="absolute bottom-0 right-0 bg-primary text-white p-1.5 rounded-full shadow-lg hover:scale-110 transition-transform">
<span class="material-symbols-outlined text-sm">photo_camera</span>
</button>
</div>
<div class="flex-1 space-y-4">
<div class="flex justify-between items-center py-2 border-b border-surface">
<div>
<p class="text-xs text-on-surface-variant font-bold uppercase tracking-wider">Họ và tên</p>
<p class="text-lg font-semibold">Minh Anh</p>
</div>
<button class="text-primary font-bold text-sm hover:underline">Chỉnh sửa</button>
</div>
<div class="flex justify-between items-center py-2">
<div>
<p class="text-xs text-on-surface-variant font-bold uppercase tracking-wider">Email</p>
<p class="text-lg font-semibold">minhanh@example.com</p>
</div>
<button class="text-primary font-bold text-sm hover:underline">Chỉnh sửa</button>
</div>
</div>
</div>
</section>
<!-- Section 5: Wallet (Bento Style) -->
<section class="col-span-12 md:col-span-4 bg-primary text-white rounded-xl p-8 relative overflow-hidden flex flex-col justify-between">
<div class="relative z-10">
<h2 class="text-sm font-bold uppercase tracking-widest opacity-80 mb-1">Ví Xu của bạn</h2>
<div class="text-4xl font-extrabold tracking-tighter">2,450 Xu</div>
</div>
<div class="relative z-10 mt-6 space-y-3">
<div class="bg-white/10 backdrop-blur-md rounded-lg p-3 flex items-center justify-between text-xs">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-sm">add_circle</span>
<span>Hoàn thành bài tập</span>
</div>
<span class="font-bold">+50 Xu</span>
</div>
<div class="bg-white/10 backdrop-blur-md rounded-lg p-3 flex items-center justify-between text-xs">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-sm">redeem</span>
<span>Đổi quà</span>
</div>
<span class="font-bold">-100 Xu</span>
</div>
</div>
<div class="absolute -right-8 -top-8 w-32 h-32 bg-white/10 rounded-full blur-3xl"></div>
<div class="absolute -left-8 -bottom-8 w-32 h-32 bg-primary-container/30 rounded-full blur-3xl"></div>
</section>
<!-- Section 2: Daily Goal -->
<section class="col-span-12 md:col-span-6 bg-surface-container-lowest rounded-xl p-8">
<h2 class="text-xl font-bold mb-6 flex items-center gap-2">
<span class="material-symbols-outlined text-primary">target</span>
Mục tiêu hàng ngày
</h2>
<div class="grid grid-cols-2 gap-4">
<label class="relative cursor-pointer">
<input class="peer sr-only" name="goal" type="radio"/>
<div class="p-4 rounded-xl border border-transparent bg-surface-container-low peer-checked:bg-primary/5 peer-checked:border-primary transition-all text-center">
<span class="block text-sm font-bold">10p</span>
<span class="text-[10px] text-on-surface-variant">Dễ dàng</span>
</div>
</label>
<label class="relative cursor-pointer">
<input checked="" class="peer sr-only" name="goal" type="radio"/>
<div class="p-4 rounded-xl border border-transparent bg-surface-container-low peer-checked:bg-primary/5 peer-checked:border-primary transition-all text-center">
<span class="block text-sm font-bold text-primary">20p</span>
<span class="text-[10px] text-primary/70">Tiêu chuẩn</span>
</div>
</label>
<label class="relative cursor-pointer">
<input class="peer sr-only" name="goal" type="radio"/>
<div class="p-4 rounded-xl border border-transparent bg-surface-container-low peer-checked:bg-primary/5 peer-checked:border-primary transition-all text-center">
<span class="block text-sm font-bold">30p</span>
<span class="text-[10px] text-on-surface-variant">Thử thách</span>
</div>
</label>
<label class="relative cursor-pointer">
<input class="peer sr-only" name="goal" type="radio"/>
<div class="p-4 rounded-xl border border-transparent bg-surface-container-low peer-checked:bg-primary/5 peer-checked:border-primary transition-all text-center">
<span class="block text-sm font-bold">1h</span>
<span class="text-[10px] text-on-surface-variant">Chuyên sâu</span>
</div>
</label>
</div>
<div class="mt-6 p-4 rounded-xl bg-primary/5 flex items-center justify-center gap-2 text-primary font-bold">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">stars</span>
<span>+50 XP mỗi ngày</span>
</div>
</section>
<!-- Section 4: TOEIC Exam Date -->
<section class="col-span-12 md:col-span-6 bg-surface-container-lowest rounded-xl p-8 flex flex-col">
<h2 class="text-xl font-bold mb-6 flex items-center gap-2">
<span class="material-symbols-outlined text-primary">calendar_month</span>
Ngày thi TOEIC
</h2>
<div class="flex-1 flex flex-col justify-center">
<div class="bg-gradient-to-br from-primary to-primary-container p-6 rounded-xl text-white text-center shadow-lg shadow-primary/20">
<p class="text-sm font-medium opacity-80 mb-2">Đếm ngược kỳ thi</p>
<p class="text-4xl font-extrabold tracking-tight">Còn 45 ngày</p>
<div class="mt-4 inline-flex items-center gap-2 px-3 py-1 bg-white/20 rounded-full text-xs">
<span class="material-symbols-outlined text-xs">event</span>
<span>25 Tháng 12, 2024</span>
</div>
</div>
<button class="mt-6 w-full py-3 rounded-full border border-primary/20 text-primary font-bold hover:bg-primary/5 transition-colors">
Thay đổi ngày thi
</button>
</div>
</section>
<!-- Section 3: Notifications -->
<section class="col-span-12 bg-surface-container-lowest rounded-xl p-8">
<h2 class="text-xl font-bold mb-8 flex items-center gap-2">
<span class="material-symbols-outlined text-primary">notifications_active</span>
Cài đặt thông báo
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-8">
<div class="flex items-center justify-between">
<div class="space-y-1">
<p class="font-bold">Nhắc nhở hàng ngày</p>
<p class="text-sm text-on-surface-variant">Tùy chỉnh thời gian học mỗi ngày</p>
<div class="mt-2 inline-flex items-center gap-2 px-3 py-1 bg-surface-container rounded-lg text-sm font-semibold">
<span class="material-symbols-outlined text-sm">schedule</span>
20:00
</div>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input checked="" class="sr-only peer" type="checkbox"/>
<div class="w-11 h-6 bg-surface-container-highest rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
<div class="flex items-center justify-between">
<div class="space-y-1">
<p class="font-bold">Cảnh báo chuỗi học tập</p>
<p class="text-sm text-on-surface-variant">Không bao giờ bỏ lỡ Streak của bạn</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input checked="" class="sr-only peer" type="checkbox"/>
<div class="w-11 h-6 bg-surface-container-highest rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
<div class="flex items-center justify-between">
<div class="space-y-1">
<p class="font-bold">Nhắc nhở mục tiêu tuần</p>
<p class="text-sm text-on-surface-variant">Theo dõi tiến độ học tập hàng tuần</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input class="sr-only peer" type="checkbox"/>
<div class="w-11 h-6 bg-surface-container-highest rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
<div class="flex items-center justify-between">
<div class="space-y-1">
<p class="font-bold">Cập nhật bảng xếp hạng</p>
<p class="text-sm text-on-surface-variant">Biết ngay khi ai đó vượt qua bạn</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input checked="" class="sr-only peer" type="checkbox"/>
<div class="w-11 h-6 bg-surface-container-highest rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
</div>
</section>
<!-- Section 6: Account -->
<section class="col-span-12 border-t border-surface-container pt-8 mt-4">
<h2 class="text-xl font-bold mb-6 flex items-center gap-2">
<span class="material-symbols-outlined text-primary">security</span>
Tài khoản &amp; Bảo mật
</h2>
<div class="bg-surface-container-lowest rounded-xl p-8 space-y-8">
<div class="flex items-center justify-between">
<div>
<p class="font-bold">Mật khẩu</p>
<p class="text-sm text-on-surface-variant">Cập nhật mật khẩu để bảo mật tài khoản</p>
</div>
<button class="px-6 py-2 rounded-full border border-outline-variant font-bold hover:bg-surface-container transition-colors">Đổi mật khẩu</button>
</div>
<div class="pt-8 border-t border-surface">
<h3 class="text-error font-bold mb-4 uppercase tracking-wider text-xs">Khu vực nguy hiểm</h3>
<div class="flex items-center justify-between p-4 bg-error/5 rounded-xl border border-error/10">
<div>
<p class="font-bold text-error">Xóa tài khoản</p>
<p class="text-sm text-error/70">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>
<button class="px-6 py-2 bg-error text-white rounded-full font-bold shadow-lg shadow-error/20 hover:bg-error/90 transition-colors">Xóa tài khoản</button>
</div>
</div>
</div>
</section>
</div>
</main>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

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