Compare commits
21 Commits
10d660cbcb
...
v1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
| abfaf397ee | |||
| 3767fc92d9 | |||
| f233652acd | |||
| 285ab987fd | |||
| 309609fccb | |||
| 3e0b3f6a6d | |||
| 088c555515 | |||
| 4bc39225ab | |||
| 427557ef96 | |||
| 1736b8a68f | |||
| efd7fac42f | |||
| 01c5ccbd93 | |||
| 77a0e38fa7 | |||
| 409706457a | |||
| 406d7039d6 | |||
| 20ae176992 | |||
| 8de8b88a3d | |||
| 857341132c | |||
| 53afcf5eb2 | |||
| ec3d400e8a | |||
| 28e866a64e |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.git
|
||||
.gitignore
|
||||
plans
|
||||
docs
|
||||
*.md
|
||||
!README.md
|
||||
16
.env.example
Normal file
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
# Docker — port exposed on host (default: 3000)
|
||||
APP_PORT=3000
|
||||
|
||||
# Supabase — https://supabase.com/dashboard/project/_/settings/api
|
||||
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
|
||||
# Alternative key name (both are supported)
|
||||
# VITE_SUPABASE_ANON_KEY=eyJ...
|
||||
|
||||
# GLM API — used by writing-check edge function (server-side only)
|
||||
# Deploy with: supabase secrets set GLM_API_KEY=<your_key>
|
||||
GLM_API_KEY=your_glm_api_key_here
|
||||
|
||||
# DBIZ API — https://ai-api.dbiz.com
|
||||
# VITE_ prefix = exposed to browser (intentional, for direct streaming without edge function hop)
|
||||
VITE_DBIZ_API_KEY=your_dbiz_api_key_here
|
||||
45
.gitea/workflows/build-on-tag.yml
Normal file
45
.gitea/workflows/build-on-tag.yml
Normal 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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -33,7 +33,6 @@ yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# package manager
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -70,6 +69,9 @@ __pycache__
|
||||
prompt.md
|
||||
ck.md
|
||||
|
||||
# TanStack Router generated
|
||||
src/routeTree.gen.ts
|
||||
|
||||
# Generated runtime layout for release/install smoke tests
|
||||
/.claude/
|
||||
|
||||
|
||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal 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
8
.idea/english.iml
generated
Normal 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>
|
||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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
13
.idea/material_theme_project_new.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
258
.tanstack/tmp/7df6e328-a904454bbfcd9697e978020268697fc8
Normal file
258
.tanstack/tmp/7df6e328-a904454bbfcd9697e978020268697fc8
Normal file
@@ -0,0 +1,258 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as WritingRouteImport } from './routes/writing'
|
||||
import { Route as VocabRouteImport } from './routes/vocab'
|
||||
import { Route as ToeicRouteImport } from './routes/toeic'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as ToeicIndexRouteImport } from './routes/toeic.index'
|
||||
import { Route as ToeicSessionRouteImport } from './routes/toeic.session'
|
||||
import { Route as ToeicResultRouteImport } from './routes/toeic.result'
|
||||
import { Route as AuthRegisterRouteImport } from './routes/auth.register'
|
||||
import { Route as AuthLoginRouteImport } from './routes/auth.login'
|
||||
import { Route as ToeicPartPartIdRouteImport } from './routes/toeic.part.$partId'
|
||||
|
||||
const WritingRoute = WritingRouteImport.update({
|
||||
id: '/writing',
|
||||
path: '/writing',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const VocabRoute = VocabRouteImport.update({
|
||||
id: '/vocab',
|
||||
path: '/vocab',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ToeicRoute = ToeicRouteImport.update({
|
||||
id: '/toeic',
|
||||
path: '/toeic',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ToeicIndexRoute = ToeicIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => ToeicRoute,
|
||||
} as any)
|
||||
const ToeicSessionRoute = ToeicSessionRouteImport.update({
|
||||
id: '/session',
|
||||
path: '/session',
|
||||
getParentRoute: () => ToeicRoute,
|
||||
} as any)
|
||||
const ToeicResultRoute = ToeicResultRouteImport.update({
|
||||
id: '/result',
|
||||
path: '/result',
|
||||
getParentRoute: () => ToeicRoute,
|
||||
} as any)
|
||||
const AuthRegisterRoute = AuthRegisterRouteImport.update({
|
||||
id: '/auth/register',
|
||||
path: '/auth/register',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthLoginRoute = AuthLoginRouteImport.update({
|
||||
id: '/auth/login',
|
||||
path: '/auth/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ToeicPartPartIdRoute = ToeicPartPartIdRouteImport.update({
|
||||
id: '/part/$partId',
|
||||
path: '/part/$partId',
|
||||
getParentRoute: () => ToeicRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/toeic': typeof ToeicRouteWithChildren
|
||||
'/vocab': typeof VocabRoute
|
||||
'/writing': typeof WritingRoute
|
||||
'/auth/login': typeof AuthLoginRoute
|
||||
'/auth/register': typeof AuthRegisterRoute
|
||||
'/toeic/result': typeof ToeicResultRoute
|
||||
'/toeic/session': typeof ToeicSessionRoute
|
||||
'/toeic/': typeof ToeicIndexRoute
|
||||
'/toeic/part/$partId': typeof ToeicPartPartIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/vocab': typeof VocabRoute
|
||||
'/writing': typeof WritingRoute
|
||||
'/auth/login': typeof AuthLoginRoute
|
||||
'/auth/register': typeof AuthRegisterRoute
|
||||
'/toeic/result': typeof ToeicResultRoute
|
||||
'/toeic/session': typeof ToeicSessionRoute
|
||||
'/toeic': typeof ToeicIndexRoute
|
||||
'/toeic/part/$partId': typeof ToeicPartPartIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/toeic': typeof ToeicRouteWithChildren
|
||||
'/vocab': typeof VocabRoute
|
||||
'/writing': typeof WritingRoute
|
||||
'/auth/login': typeof AuthLoginRoute
|
||||
'/auth/register': typeof AuthRegisterRoute
|
||||
'/toeic/result': typeof ToeicResultRoute
|
||||
'/toeic/session': typeof ToeicSessionRoute
|
||||
'/toeic/': typeof ToeicIndexRoute
|
||||
'/toeic/part/$partId': typeof ToeicPartPartIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/toeic'
|
||||
| '/vocab'
|
||||
| '/writing'
|
||||
| '/auth/login'
|
||||
| '/auth/register'
|
||||
| '/toeic/result'
|
||||
| '/toeic/session'
|
||||
| '/toeic/'
|
||||
| '/toeic/part/$partId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/vocab'
|
||||
| '/writing'
|
||||
| '/auth/login'
|
||||
| '/auth/register'
|
||||
| '/toeic/result'
|
||||
| '/toeic/session'
|
||||
| '/toeic'
|
||||
| '/toeic/part/$partId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/toeic'
|
||||
| '/vocab'
|
||||
| '/writing'
|
||||
| '/auth/login'
|
||||
| '/auth/register'
|
||||
| '/toeic/result'
|
||||
| '/toeic/session'
|
||||
| '/toeic/'
|
||||
| '/toeic/part/$partId'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
ToeicRoute: typeof ToeicRouteWithChildren
|
||||
VocabRoute: typeof VocabRoute
|
||||
WritingRoute: typeof WritingRoute
|
||||
AuthLoginRoute: typeof AuthLoginRoute
|
||||
AuthRegisterRoute: typeof AuthRegisterRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/writing': {
|
||||
id: '/writing'
|
||||
path: '/writing'
|
||||
fullPath: '/writing'
|
||||
preLoaderRoute: typeof WritingRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/vocab': {
|
||||
id: '/vocab'
|
||||
path: '/vocab'
|
||||
fullPath: '/vocab'
|
||||
preLoaderRoute: typeof VocabRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/toeic': {
|
||||
id: '/toeic'
|
||||
path: '/toeic'
|
||||
fullPath: '/toeic'
|
||||
preLoaderRoute: typeof ToeicRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/toeic/': {
|
||||
id: '/toeic/'
|
||||
path: '/'
|
||||
fullPath: '/toeic/'
|
||||
preLoaderRoute: typeof ToeicIndexRouteImport
|
||||
parentRoute: typeof ToeicRoute
|
||||
}
|
||||
'/toeic/session': {
|
||||
id: '/toeic/session'
|
||||
path: '/session'
|
||||
fullPath: '/toeic/session'
|
||||
preLoaderRoute: typeof ToeicSessionRouteImport
|
||||
parentRoute: typeof ToeicRoute
|
||||
}
|
||||
'/toeic/result': {
|
||||
id: '/toeic/result'
|
||||
path: '/result'
|
||||
fullPath: '/toeic/result'
|
||||
preLoaderRoute: typeof ToeicResultRouteImport
|
||||
parentRoute: typeof ToeicRoute
|
||||
}
|
||||
'/auth/register': {
|
||||
id: '/auth/register'
|
||||
path: '/auth/register'
|
||||
fullPath: '/auth/register'
|
||||
preLoaderRoute: typeof AuthRegisterRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/auth/login': {
|
||||
id: '/auth/login'
|
||||
path: '/auth/login'
|
||||
fullPath: '/auth/login'
|
||||
preLoaderRoute: typeof AuthLoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/toeic/part/$partId': {
|
||||
id: '/toeic/part/$partId'
|
||||
path: '/part/$partId'
|
||||
fullPath: '/toeic/part/$partId'
|
||||
preLoaderRoute: typeof ToeicPartPartIdRouteImport
|
||||
parentRoute: typeof ToeicRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ToeicRouteChildren {
|
||||
ToeicResultRoute: typeof ToeicResultRoute
|
||||
ToeicSessionRoute: typeof ToeicSessionRoute
|
||||
ToeicIndexRoute: typeof ToeicIndexRoute
|
||||
ToeicPartPartIdRoute: typeof ToeicPartPartIdRoute
|
||||
}
|
||||
|
||||
const ToeicRouteChildren: ToeicRouteChildren = {
|
||||
ToeicResultRoute: ToeicResultRoute,
|
||||
ToeicSessionRoute: ToeicSessionRoute,
|
||||
ToeicIndexRoute: ToeicIndexRoute,
|
||||
ToeicPartPartIdRoute: ToeicPartPartIdRoute,
|
||||
}
|
||||
|
||||
const ToeicRouteWithChildren = ToeicRoute._addFileChildren(ToeicRouteChildren)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ToeicRoute: ToeicRouteWithChildren,
|
||||
VocabRoute: VocabRoute,
|
||||
WritingRoute: WritingRoute,
|
||||
AuthLoginRoute: AuthLoginRoute,
|
||||
AuthRegisterRoute: AuthRegisterRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
595
Claude.md
595
Claude.md
@@ -1,7 +1,8 @@
|
||||
# Claude Project Context — English Learning App (TOEIC Focus)
|
||||
|
||||
> File này dùng để cung cấp context đầy đủ cho Claude khi làm việc với dự án.
|
||||
> File này cung cấp context đầy đủ cho Claude khi làm việc với dự án.
|
||||
> Cập nhật file này mỗi khi có quyết định kiến trúc mới.
|
||||
> **Last updated**: Phase 2 done — thêm Phase 3 Retention & Monetization, đẩy Speaking AI và Full TOEIC sang Phase 4 & 5
|
||||
|
||||
---
|
||||
|
||||
@@ -11,15 +12,7 @@
|
||||
**Target users**: Sinh viên, người đi làm tại Việt Nam cần TOEIC, IELTS, hoặc học tiếng Anh tổng quát
|
||||
**Focus chính**: TOEIC (mở rộng market), sau đó IELTS
|
||||
**Giai đoạn hiện tại**: Phase 1 — MVP, validate market
|
||||
|
||||
---
|
||||
|
||||
## Mục tiêu Phase 1
|
||||
|
||||
- Ra sản phẩm web dùng thử được, **không cần đăng nhập**
|
||||
- Validate 2 tính năng cốt lõi: luyện đề TOEIC + AI writing checker
|
||||
- Test xem có người dùng thật, có traction không
|
||||
- **Không over-engineer** — Supabase tạm thời, đổi sau nếu có traction
|
||||
**Roadmap**: 5 phases — MVP → Auth & Progress → Retention & Monetization → Speaking AI → Full TOEIC
|
||||
|
||||
---
|
||||
|
||||
@@ -28,59 +21,66 @@
|
||||
### Frontend
|
||||
| Layer | Tech | Ghi chú |
|
||||
|---|---|---|
|
||||
| Framework | **React** (Vite) | |
|
||||
| Routing | **TanStack Router** | Type-safe routing |
|
||||
| Framework | **React** + **Vite** + **TypeScript** | |
|
||||
| Routing | **TanStack Router** | File-based, type-safe |
|
||||
| Server state | **TanStack Query** | Fetch, cache, sync API data |
|
||||
| Client state | **Zustand** | UI state, localStorage sync |
|
||||
| Styling | **Tailwind CSS** | Mobile-first |
|
||||
| Client state | **Zustand** | UI state + localStorage persist |
|
||||
| Styling | **Tailwind CSS** | Desktop-first |
|
||||
| UI Components | **shadcn/ui** | Dùng khi cần, không bắt buộc |
|
||||
|
||||
### Backend (Phase 1 — tạm thời)
|
||||
| Layer | Tech | Ghi chú |
|
||||
|---|---|---|
|
||||
| Database | **Supabase** (PostgreSQL managed) | Free tier, migrate sau |
|
||||
| API | **Supabase JS SDK** | Gọi thẳng từ React |
|
||||
| Server functions | **Supabase Edge Functions** (Deno) | Xử lý AI API call, giấu key |
|
||||
|
||||
> ⚠️ **Supabase chỉ dùng Phase 1** để ra sản phẩm nhanh.
|
||||
> Phase 2 migrate sang **NestJS + PostgreSQL native** khi có traction.
|
||||
> Schema PostgreSQL thiết kế chuẩn ngay từ đầu để migrate không đau.
|
||||
|
||||
### Backend (Phase 2 — kế hoạch)
|
||||
| Layer | Tech |
|
||||
### Design System (từ Stitch export)
|
||||
| Token | Value |
|
||||
|---|---|
|
||||
| Framework | **NestJS** |
|
||||
| ORM | **Prisma** hoặc **TypeORM** |
|
||||
| Database | **PostgreSQL** (self-hosted) |
|
||||
| Auth | **JWT** + Google OAuth + Zalo OAuth |
|
||||
| Mobile | **Flutter** (iOS + Android) |
|
||||
| Font | Plus Jakarta Sans + Material Symbols Outlined |
|
||||
| Primary | #2563EB |
|
||||
| Success | #16A34A |
|
||||
| Danger | #DC2626 |
|
||||
| Background | #F8FAFC |
|
||||
| Card | #FFFFFF |
|
||||
| Border radius | 12–16px |
|
||||
| Shadow | soft, subtle |
|
||||
|
||||
### Responsive Layout
|
||||
| Breakpoint | Layout |
|
||||
|---|---|
|
||||
| Desktop (1280px) | Sidebar trái cố định (240px) + main content — **LAYOUT CHÍNH** |
|
||||
| Tablet (768px) | Sidebar thu gọn icon-only |
|
||||
| Mobile (375px) | Ẩn sidebar, hiện bottom navigation bar |
|
||||
|
||||
### Backend
|
||||
| Phase | Tech | Ghi chú |
|
||||
|---|---|---|
|
||||
| Phase 1 | **Supabase** (PostgreSQL + Edge Functions + JS SDK) | Tạm thời, migrate sau |
|
||||
| Phase 2+ | **NestJS** + **PostgreSQL** native | Khi có traction |
|
||||
|
||||
> ⚠️ Supabase chỉ dùng Phase 1. Schema PostgreSQL thiết kế chuẩn ngay từ đầu để migrate không đau.
|
||||
|
||||
### AI
|
||||
| Layer | Tech | Ghi chú |
|
||||
|---|---|---|
|
||||
| Provider | **GLM (Z.ai API)** — `open.bigmodel.cn` | Rẻ, OpenAI-compatible |
|
||||
| Model | **GLM-4** hoặc **GLM-4.7** | Test chất lượng chấm writing |
|
||||
| Fallback | OpenAI / Claude API | Nếu GLM không đủ chất lượng |
|
||||
|
||||
> GLM API tương thích OpenAI format → swap provider không cần đổi code.
|
||||
| Provider | **GLM (Z.ai API)** | Rẻ, OpenAI-compatible format |
|
||||
| Endpoint | `open.bigmodel.cn/api/paas/v4` | |
|
||||
| Model | GLM-4 / GLM-4.7 | |
|
||||
| Fallback | OpenAI / Claude API | Swap dễ vì API compatible |
|
||||
| Gọi từ | Supabase Edge Function (Phase 1) → NestJS service (Phase 2+) | Giấu API key |
|
||||
|
||||
### Deploy
|
||||
| Layer | Tech |
|
||||
|---|---|
|
||||
| Frontend | **Self-hosted server** (có sẵn) |
|
||||
| Backend | **Self-hosted server** (có sẵn) |
|
||||
| Database | Supabase Cloud (Phase 1) → self-hosted PostgreSQL (Phase 2) |
|
||||
| Frontend | Self-hosted server (có sẵn) |
|
||||
| Backend | Self-hosted server (có sẵn) |
|
||||
| Database | Supabase Cloud (Phase 1) → self-hosted PostgreSQL (Phase 2+) |
|
||||
|
||||
---
|
||||
|
||||
## Database Schema (PostgreSQL — Phase 1)
|
||||
## Database Schema (PostgreSQL)
|
||||
|
||||
```sql
|
||||
-- Câu hỏi TOEIC
|
||||
CREATE TABLE questions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
part INT NOT NULL, -- 1 đến 7
|
||||
type TEXT, -- photo, q&a, incomplete_sentence, etc.
|
||||
type TEXT, -- photo | q&a | incomplete_sentence | etc.
|
||||
content TEXT NOT NULL, -- nội dung câu hỏi / đoạn văn
|
||||
options JSONB, -- ["A. ...", "B. ...", "C. ...", "D. ..."]
|
||||
answer TEXT NOT NULL, -- "A"
|
||||
@@ -101,158 +101,411 @@ CREATE TABLE vocab (
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- Phase 2 sẽ thêm: users, user_progress, writing_submissions, test_sessions
|
||||
-- Phase 2+
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
password TEXT NOT NULL, -- hashed
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE user_progress (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id),
|
||||
type TEXT, -- test | vocab | writing
|
||||
reference_id UUID,
|
||||
data JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE writing_submissions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id),
|
||||
content TEXT NOT NULL,
|
||||
feedback JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Design
|
||||
## TypeScript Interfaces
|
||||
|
||||
### Supabase SDK (Phase 1)
|
||||
```typescript
|
||||
// Lấy câu hỏi theo Part
|
||||
supabase.from('questions').select('*').eq('part', 1).limit(10)
|
||||
interface Question {
|
||||
id: string
|
||||
part: number
|
||||
type: string
|
||||
content: string
|
||||
options: string[]
|
||||
answer: string
|
||||
explanation: string
|
||||
audioUrl?: string
|
||||
imageUrl?: string
|
||||
}
|
||||
|
||||
// Lấy từ vựng theo chủ đề
|
||||
supabase.from('vocab').select('*').eq('topic', 'business')
|
||||
```
|
||||
interface VocabWord {
|
||||
id: string
|
||||
word: string
|
||||
phonetic: string
|
||||
meaningVi: string
|
||||
topic: 'business' | 'office' | 'travel' | 'finance' | 'hr' | 'marketing'
|
||||
example: string
|
||||
}
|
||||
|
||||
### Supabase Edge Functions
|
||||
```
|
||||
POST /functions/v1/writing-check
|
||||
Body : { content: string }
|
||||
Return : {
|
||||
score: string, // band score ước tính
|
||||
grammar: string[], // lỗi ngữ pháp + gợi ý sửa
|
||||
vocabulary: string[], // nhận xét từ vựng
|
||||
structure: string, // nhận xét bố cục (tiếng Việt)
|
||||
improved_version: string, // bài viết lại tốt hơn
|
||||
summary: string // tổng nhận xét (tiếng Việt)
|
||||
}
|
||||
interface TestResult {
|
||||
testId: string
|
||||
part: number
|
||||
score: number
|
||||
total: number
|
||||
duration: number
|
||||
answers: { questionId: string; selected: string; correct: boolean }[]
|
||||
completedAt: Date
|
||||
}
|
||||
|
||||
interface WritingFeedback {
|
||||
score: string
|
||||
grammar: string[]
|
||||
vocabulary: string[]
|
||||
structure: string
|
||||
improvedVersion: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
// Phase 2+
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
createdAt: Date
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cấu trúc thư mục (React)
|
||||
## Cấu trúc thư mục
|
||||
|
||||
```
|
||||
src/
|
||||
├── pages/
|
||||
│ ├── Home.tsx ← Landing page + CTA
|
||||
│ ├── ToeicPractice.tsx ← Chọn Part để luyện
|
||||
│ ├── TestSession.tsx ← Làm bài (timer + câu hỏi)
|
||||
│ ├── TestResult.tsx ← Kết quả + giải thích đáp án
|
||||
│ ├── WritingChecker.tsx ← AI Writing Checker
|
||||
│ └── Vocabulary.tsx ← Flashcard từ vựng
|
||||
├── routes/
|
||||
│ ├── index.tsx ← Trang chủ (/)
|
||||
│ ├── toeic/
|
||||
│ │ ├── index.tsx ← Chọn Part (/toeic)
|
||||
│ │ ├── part.$partId.tsx ← Config số câu (/toeic/part/$partId)
|
||||
│ │ ├── session.tsx ← Làm bài (/toeic/session)
|
||||
│ │ └── result.tsx ← Kết quả + đáp án (/toeic/result)
|
||||
│ ├── writing.tsx ← AI Writing Checker (/writing)
|
||||
│ ├── vocab.tsx ← Flashcard (/vocab)
|
||||
│ └── auth/ ← Phase 2
|
||||
│ ├── login.tsx ← Đăng nhập (/auth/login)
|
||||
│ └── register.tsx ← Đăng ký (/auth/register)
|
||||
├── components/
|
||||
│ ├── layout/
|
||||
│ │ ├── Sidebar.tsx ← Desktop sidebar
|
||||
│ │ └── BottomNav.tsx ← Mobile bottom nav
|
||||
│ ├── QuestionCard.tsx
|
||||
│ ├── FlashCard.tsx
|
||||
│ ├── WritingFeedback.tsx
|
||||
│ ├── ProgressBar.tsx
|
||||
│ ├── ProgressRing.tsx
|
||||
│ └── Timer.tsx
|
||||
├── hooks/
|
||||
│ ├── useQuestions.ts ← TanStack Query: fetch questions
|
||||
│ ├── useVocab.ts ← TanStack Query: fetch vocab
|
||||
│ └── useWritingCheck.ts ← TanStack Mutation: gọi Edge Function
|
||||
├── store/
|
||||
│ ├── testStore.ts ← Zustand: trạng thái bài thi
|
||||
│ └── vocabStore.ts ← Zustand: progress flashcard (sync localStorage)
|
||||
│ ├── vocabStore.ts ← Zustand: flashcard progress (persist localStorage)
|
||||
│ └── authStore.ts ← Zustand: user session (Phase 2)
|
||||
├── hooks/
|
||||
│ ├── useQuestions.ts ← TanStack Query
|
||||
│ ├── useVocab.ts ← TanStack Query
|
||||
│ └── useWritingCheck.ts ← TanStack Mutation → Edge Function
|
||||
├── lib/
|
||||
│ └── supabase.ts ← Supabase client init
|
||||
└── utils/
|
||||
└── rateLimiter.ts ← Rate limit AI Writing (3 lần/ngày/IP, localStorage)
|
||||
└── rateLimiter.ts ← Rate limit 3 lần/ngày/IP (localStorage)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Routes (TanStack Router)
|
||||
## Supabase Edge Function — AI Writing Checker
|
||||
|
||||
```
|
||||
/ ← Landing page
|
||||
/toeic ← Chọn Part (1–7)
|
||||
/toeic/part/$partId ← Config bài thi (số câu)
|
||||
/toeic/session ← Làm bài
|
||||
/toeic/result ← Kết quả + đáp án
|
||||
/writing ← AI Writing Checker
|
||||
/vocab ← Flashcard (filter theo topic)
|
||||
```typescript
|
||||
// supabase/functions/writing-check/index.ts
|
||||
import { serve } from "https://deno.land/std/http/server.ts"
|
||||
|
||||
serve(async (req) => {
|
||||
const { content } = await req.json()
|
||||
|
||||
const response = await fetch("https://open.bigmodel.cn/api/paas/v4/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${Deno.env.get("GLM_API_KEY")}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "glm-4",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are an expert English writing evaluator for TOEIC/IELTS.
|
||||
Evaluate the writing and return ONLY valid JSON:
|
||||
{
|
||||
"score": "estimated band score",
|
||||
"grammar": ["error + fix"],
|
||||
"vocabulary": ["suggestion"],
|
||||
"structure": "feedback in Vietnamese",
|
||||
"improved_version": "rewritten version",
|
||||
"summary": "overall feedback in Vietnamese"
|
||||
}`
|
||||
},
|
||||
{ role: "user", content }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
const result = JSON.parse(data.choices[0].message.content)
|
||||
return new Response(JSON.stringify(result), {
|
||||
headers: { "Content-Type": "application/json" }
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tính năng Phase 1 (đã chốt)
|
||||
|
||||
### ✅ 1. Luyện đề TOEIC
|
||||
- Mini test theo từng Part (Part 1 → Part 7)
|
||||
- Chọn số câu: 10 / 20 / full part
|
||||
- Làm bài có đếm giờ
|
||||
- Submit → xem điểm + đáp án đúng/sai + giải thích tiếng Việt
|
||||
- Lịch sử kết quả lưu **localStorage** (Zustand persist)
|
||||
- Thống kê điểm yếu theo Part
|
||||
|
||||
### ✅ 2. AI Writing Checker (Core Differentiator)
|
||||
- Nhập bài writing tự do (TOEIC email, IELTS task, general)
|
||||
- Gọi GLM qua Supabase Edge Function (API key an toàn)
|
||||
- Feedback JSON: band score, lỗi ngữ pháp, từ vựng, cấu trúc, bài mẫu
|
||||
- Rate limit: **3 lần / ngày / IP** (không cần login)
|
||||
- Hiển thị feedback có highlight, dễ đọc trên mobile
|
||||
|
||||
### ✅ 3. Flashcard Từ vựng TOEIC
|
||||
- 6 chủ đề: Business, Office, Travel, Finance, HR, Marketing
|
||||
- Mỗi card: từ + phiên âm + nghĩa Việt + câu ví dụ
|
||||
- Flip card animation
|
||||
- Đánh dấu: Đã thuộc / Cần ôn
|
||||
- Progress lưu localStorage (Zustand persist)
|
||||
- Filter theo chủ đề
|
||||
## Roadmap — 4 Phases
|
||||
|
||||
---
|
||||
|
||||
## Tính năng KHÔNG có ở Phase 1
|
||||
### PHASE 1 — MVP (Hiện tại) 🚧
|
||||
|
||||
| Tính năng | Khi nào có |
|
||||
**Mục tiêu**: Ra sản phẩm web dùng thử không cần login, validate market
|
||||
|
||||
**Stack**: React + Vite + TypeScript + TanStack + Zustand + Tailwind + Supabase + GLM
|
||||
|
||||
**Tính năng**:
|
||||
- ✅ Luyện đề TOEIC mini test theo từng Part (Part 1–7)
|
||||
- ✅ Chọn số câu: 10 / 20 / full part, có đếm giờ
|
||||
- ✅ Submit → xem điểm + đáp án + giải thích tiếng Việt
|
||||
- ✅ Lịch sử kết quả + thống kê điểm yếu theo Part (localStorage)
|
||||
- ✅ AI Writing Checker (GLM, 3 lần/ngày/IP, không cần login)
|
||||
- ✅ Flashcard từ vựng TOEIC (6 chủ đề, localStorage progress)
|
||||
|
||||
**Không có**:
|
||||
- ❌ Auth / login
|
||||
- ❌ Progress sync server
|
||||
- ❌ Thanh toán
|
||||
- ❌ Flutter / mobile app
|
||||
- ❌ Full mock test
|
||||
|
||||
**Timeline**: 5 tuần
|
||||
|
||||
**Done khi**:
|
||||
- ≥ 50 câu hỏi mỗi Part (Part 1–7)
|
||||
- AI Writing Checker phản hồi < 5 giây
|
||||
- Không lỗi hiển thị trên Chrome mobile
|
||||
- 20+ người dùng thật đã dùng thử
|
||||
- Không critical bug sau 1 tuần beta
|
||||
|
||||
---
|
||||
|
||||
### PHASE 2 — Auth & Progress
|
||||
|
||||
**Mục tiêu**: Giữ chân user, sync progress server-side, hiểu behavior trước khi monetize
|
||||
|
||||
**Trigger**: Phase 1 có traction — 200+ MAU hoặc feedback tích cực
|
||||
|
||||
**Stack thay đổi**:
|
||||
- Migrate Supabase → **NestJS + PostgreSQL native**
|
||||
- Thêm **Redis** cho cache + session
|
||||
|
||||
**Guest Access (chưa đăng ký)**:
|
||||
- Xem preview 1 bài test dạng read-only (thấy câu hỏi, không làm được)
|
||||
- Không cho submit đáp án, không xem kết quả
|
||||
- Hiện modal "Đăng ký để luyện thi" khi cố tương tác
|
||||
- Flashcard: xem vài card đầu, bị chặn sau đó
|
||||
- AI Writing: không dùng được, hiện CTA đăng ký
|
||||
|
||||
**Auth — Đăng ký**:
|
||||
- Form: **Tên + Email + Password** (chỉ 3 field)
|
||||
- Không xác thực email, không OTP, không confirm
|
||||
- Đăng ký xong → **redirect home luôn**
|
||||
|
||||
**Auth — Đăng nhập**:
|
||||
- Email + Password
|
||||
- Redirect về trang trước hoặc home
|
||||
|
||||
**Sau khi đăng nhập**:
|
||||
- Làm bài không giới hạn
|
||||
- Progress sync server-side: lịch sử thi, từ vựng, writing submissions
|
||||
- Dashboard cá nhân: streak, biểu đồ điểm, điểm yếu theo Part
|
||||
- AI Writing: 10 lần/ngày
|
||||
|
||||
**Không có ở Phase 2**:
|
||||
- ❌ Xác thực email
|
||||
- ❌ Quên mật khẩu
|
||||
- ❌ Google / Zalo OAuth
|
||||
- ❌ Flutter / mobile app
|
||||
- ❌ Thanh toán / freemium
|
||||
- ❌ Notification
|
||||
|
||||
---
|
||||
|
||||
### PHASE 3 — Xu System & Gamification ← Tiếp theo
|
||||
|
||||
**Mục tiêu**: Tạo thói quen học hàng ngày, giữ chân user bằng Xu economy + gamification
|
||||
**Platform**: Web only (không Flutter ở phase này)
|
||||
**Trigger**: Phase 2 có user đăng ký, cần convert sang returning users
|
||||
|
||||
---
|
||||
|
||||
#### Xu Economy — Trung tâm của Phase 3
|
||||
|
||||
```
|
||||
Học hàng ngày / xem ads → kiếm Xu
|
||||
↓
|
||||
Xu → dùng tính năng premium
|
||||
↓
|
||||
Hết Xu → xem ads thêm
|
||||
(nạp tiền thật → Phase 4)
|
||||
```
|
||||
|
||||
**Kiếm Xu (miễn phí)**:
|
||||
| Hành động | Xu nhận |
|
||||
|---|---|
|
||||
| Đăng nhập / Auth | Phase 2 |
|
||||
| Progress sync server | Phase 2 |
|
||||
| Flutter mobile app | Phase 2 |
|
||||
| NestJS backend | Phase 2 |
|
||||
| Full TOEIC mock test | Phase 2 |
|
||||
| Thanh toán | Phase 2 |
|
||||
| Speaking / Pronunciation AI | Phase 3+ |
|
||||
| Cộng đồng / Forum | Phase 3+ |
|
||||
| Lớp học / Giáo viên | Phase 3+ |
|
||||
| Đăng ký lần đầu (welcome bonus) | 50 Xu |
|
||||
| Hoàn thành daily goal | 10 Xu |
|
||||
| Streak milestone (7 / 30 / 100 ngày) | 20 / 50 / 100 Xu |
|
||||
| Xem rewarded ads trên web | 5 Xu / video (tối đa 5 video/ngày) |
|
||||
|
||||
---
|
||||
|
||||
## Timeline Phase 1 (5 tuần)
|
||||
|
||||
| Tuần | Công việc |
|
||||
**Dùng Xu**:
|
||||
| Tính năng | Chi phí |
|
||||
|---|---|
|
||||
| **1** | Setup Supabase schema + seed đề TOEIC (≥50 câu/part) + React + Vite + Tailwind + TanStack + Zustand |
|
||||
| **2** | UI luyện đề: chọn Part → làm bài → kết quả + giải thích |
|
||||
| **3** | Supabase Edge Function + GLM API → AI Writing Checker + rate limit |
|
||||
| **4** | Flashcard UI + Zustand persist + mobile polish + landing page |
|
||||
| **5** | Bug fix + test mobile thật + deploy lên server + beta ~20 người |
|
||||
| Streak freeze (bảo vệ 1 ngày) | 20 Xu |
|
||||
| Thêm 5 lượt AI Writing (GLM-4.7) | 30 Xu |
|
||||
| 1 lượt AI Writing cao cấp (GPT-4o) | 15 Xu |
|
||||
| Mở thêm bài thi khi hết giới hạn free | 10 Xu |
|
||||
|
||||
> ⚠️ Chưa có nạp Xu bằng tiền thật ở Phase 3 — chỉ kiếm qua học + ads.
|
||||
> Nạp tiền thật (VNPay/MoMo) → Phase 4.
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done — Phase 1
|
||||
|
||||
- [ ] ≥ 50 câu hỏi mỗi Part (Part 1–7)
|
||||
- [ ] AI Writing Checker phản hồi < 5 giây
|
||||
- [ ] Không lỗi hiển thị trên Chrome mobile
|
||||
- [ ] 20+ người dùng thật đã dùng thử
|
||||
- [ ] Không có critical bug sau 1 tuần beta
|
||||
#### AI Model Tier
|
||||
| Tier | Model | Free limit | Dùng Xu |
|
||||
|---|---|---|---|
|
||||
| Free | GLM-4 base | 3 lần/ngày | — |
|
||||
| Standard | GLM-4.7 | — | 30 Xu / 5 lượt |
|
||||
| Premium | GPT-4o / Claude | — | 15 Xu / lượt |
|
||||
|
||||
---
|
||||
|
||||
## Rủi ro đã nhận diện
|
||||
#### Gamification
|
||||
- **Streak hàng ngày**: học ít nhất 1 bài/ngày giữ chuỗi
|
||||
- **XP points**: mỗi bài thi / flashcard / writing check → XP
|
||||
- **Cấp độ**: Beginner → Bronze → Silver → Gold → Master (theo XP tích luỹ)
|
||||
- **Streak freeze**: dùng Xu bảo vệ streak khi bận — hook mạnh nhất
|
||||
- **Weekly goal**: đặt mục tiêu tuần, hoàn thành → badge + thưởng Xu
|
||||
|
||||
| Rủi ro | Mức độ | Xử lý |
|
||||
|---|---|---|
|
||||
| Content đề TOEIC chất lượng thấp (crawl) | 🔴 Cao | Crawl ít + clean kỹ, tự soạn dần để thay thế |
|
||||
| GLM chấm writing không đủ tin cậy | 🟡 TB | Test prompt kỹ, fallback OpenAI-compatible nếu cần |
|
||||
| Latency GLM từ VN cao | 🟡 TB | Benchmark thực tế tuần 3 |
|
||||
| User mất progress (localStorage) | 🟡 TB | Chấp nhận ở MVP, auth Phase 2 giải quyết |
|
||||
| Bản quyền đề TOEIC crawl | 🟡 TB | Dùng để seed nhanh, thay bằng nội dung tự soạn |
|
||||
#### Leaderboard
|
||||
- Bảng xếp hạng tuần theo XP (reset mỗi tuần, không tích luỹ)
|
||||
- Hiện top 10 + vị trí của bản thân
|
||||
- Chia sẻ kết quả 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 cá 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 có đủ 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**: 5–6 tuần
|
||||
|
||||
---
|
||||
|
||||
### PHASE 4 — Speaking AI
|
||||
|
||||
**Mục tiêu**: Tăng differentiation, cover kỹ năng Speaking cho IELTS/TOEIC
|
||||
|
||||
**Trigger**: Phase 3 ổn định, có doanh thu đều
|
||||
|
||||
**Tính năng**:
|
||||
- AI Speaking Coach: record giọng → AI chấm phát âm + so sánh native speaker
|
||||
- Luyện IELTS Speaking Part 1/2/3 (mock interview với AI)
|
||||
- Shadow reading: nghe → lặp lại → AI so sánh độ chính xác
|
||||
- Pronunciation scoring có điểm số và progress chart
|
||||
|
||||
**Tech mới**:
|
||||
- Speech-to-text: Whisper API hoặc Google Speech-to-Text
|
||||
- Text-to-speech: native audio
|
||||
- WebRTC / MediaRecorder API (web) + Flutter audio recording
|
||||
|
||||
---
|
||||
|
||||
### PHASE 5 — Full TOEIC Mock Test
|
||||
|
||||
**Mục tiêu**: Platform luyện TOEIC toàn diện chuẩn ETS
|
||||
|
||||
**Trigger**: Phase 4 xong, cần nội dung premium cao cấp hơn
|
||||
|
||||
**Tính năng**:
|
||||
- Full TOEIC test chuẩn ETS: 200 câu, 120 phút
|
||||
- Audio Listening chuẩn cho Part 1–4
|
||||
- Auto-score: tính điểm 10–990 theo bảng quy đổi ETS
|
||||
- Phân tích chi tiết: điểm mạnh/yếu từng Part, so sánh lần trước
|
||||
- Bộ đề theo năm: ETS 2023, 2024, Actual Test...
|
||||
- Đếm ngược đến ngày thi + lịch ôn tập gợi ý
|
||||
|
||||
---
|
||||
|
||||
@@ -260,19 +513,49 @@ src/
|
||||
|
||||
| Quyết định | Lý do |
|
||||
|---|---|
|
||||
| Không có Auth Phase 1 | Giảm scope, user dùng thử không cần tạo tài khoản |
|
||||
| Supabase thay NestJS tạm | Ra nhanh hơn 2–3 tuần, schema chuẩn để migrate sau |
|
||||
| GLM thay OpenAI/Claude | Rẻ hơn đáng kể, API compatible, đủ để test |
|
||||
| Web-only, không Flutter | Tập trung 1 platform, Flutter Phase 2 reuse API |
|
||||
| TanStack Query + Zustand | TanStack cho server state, Zustand cho client/local state |
|
||||
| localStorage cho progress | Đủ cho MVP, không cần backend phức tạp |
|
||||
| Không auth Phase 1 | Giảm scope, validate market trước |
|
||||
| Email only Phase 2, không OAuth | Đơn giản nhất để build, OAuth Phase 4+ |
|
||||
| Không xác thực email Phase 2 | MVP — giảm friction đăng ký tối đa |
|
||||
| Chỉ 3 field đăng ký (tên/email/pass) | Friction thấp nhất, đủ để identify user |
|
||||
| Guest chỉ xem preview, không làm được | Buộc đăng ký để dùng, giúp thu thập user data |
|
||||
| Không thanh toán Phase 2 | Hiểu behavior trước khi charge tiền |
|
||||
| Không Flutter Phase 2 | Web đã responsive, Flutter Phase 3 khi có 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 2–3 tuần, schema chuẩn để migrate sau |
|
||||
| GLM thay OpenAI/Claude | Rẻ hơn, OpenAI-compatible, swap dễ |
|
||||
| Desktop-first | Target TOEIC learner hay dùng máy tính |
|
||||
| TanStack Query + Zustand | Server state tách biệt client state rõ ràng |
|
||||
| NestJS Phase 2 | Supabase đủ để validate, NestJS khi scale |
|
||||
| Speaking AI Phase 4 | Cần infra + monetization ổn định trước khi làm realtime audio |
|
||||
| Full mock test Phase 5 | Cần content team + audio, không phải tech problem |
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Ngôn ngữ**: Tiếng Việt cho UI người dùng, English cho code/comments
|
||||
- **Ngôn ngữ**: Tiếng Việt cho UI người dùng, English cho code/comments/type names
|
||||
- **Giải thích đáp án**: Luôn bằng tiếng Việt
|
||||
- **AI feedback**: Mix Việt-Anh (nhận xét tổng thể tiếng Việt, ví dụ sửa tiếng Anh)
|
||||
- **Mobile-first**: Test trên màn hình 375px trước, desktop sau
|
||||
- **YAGNI / KISS**: Không build thứ chưa cần, Phase 1 xong mới nghĩ Phase 2
|
||||
- **AI feedback**: Nhận xét tổng thể tiếng Việt, ví dụ sửa tiếng Anh
|
||||
- **Desktop-first**: Design và 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ù dùng Supabase, PostgreSQL schema phải production-ready
|
||||
- **Coins = "Xu"**: Dùng nhất quán trong code lẫn UI
|
||||
|
||||
---
|
||||
|
||||
## Rủi ro đã nhận diện
|
||||
|
||||
| Rủi ro | Mức độ | Xử lý |
|
||||
|---|---|---|
|
||||
| Content đề TOEIC chất lượng thấp (crawl) | 🔴 Cao | Crawl ít + clean kỹ, tự soạn dần thay thế |
|
||||
| GLM chấm writing không đủ tin cậy | 🟡 TB | Test prompt kỹ, fallback OpenAI nếu cần |
|
||||
| Latency GLM từ VN cao | 🟡 TB | Benchmark thực tế, cache response nếu cần |
|
||||
| User mất progress (localStorage Phase 1) | 🟡 TB | Chấp nhận Phase 1, auth Phase 2 giải quyết |
|
||||
| Bản quyền đề TOEIC crawl | 🟡 TB | Seed nhanh, thay bằng nội dung tự soạn dần |
|
||||
| Supabase free tier limit | 🟢 Thấp | 500MB đủ Phase 1, migrate trước khi hit limit |
|
||||
| AdSense bị block (adblocker) | 🟡 TB | Rewarded ads mobile bù 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
36
Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
# ============================================================
|
||||
# Stage 1 — Build
|
||||
# Node 22 Alpine: smallest image with full npm support
|
||||
# ============================================================
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first (layer cache — only re-runs if package.json changes)
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source and build — no VITE_* args needed at build time.
|
||||
# Supabase keys are injected at runtime via docker/entrypoint.sh → window.__ENV__
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ============================================================
|
||||
# Stage 2 — Serve
|
||||
# Nginx Alpine: ~25MB final image
|
||||
# ============================================================
|
||||
FROM nginx:alpine AS runner
|
||||
|
||||
# Replace default nginx config with SPA config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built static files from builder
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Entrypoint generates env.js from runtime env vars before starting nginx
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["/entrypoint.sh"]
|
||||
25
components.json
Normal file
25
components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
83
docker-build.sh
Executable file
83
docker-build.sh
Executable 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
18
docker-compose.yml
Normal 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
10
docker/entrypoint.sh
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Generate runtime env.js from container environment variables
|
||||
cat > /usr/share/nginx/html/env.js <<EOF
|
||||
window.__ENV__ = {
|
||||
VITE_SUPABASE_URL: "${VITE_SUPABASE_URL}",
|
||||
VITE_SUPABASE_ANON_KEY: "${VITE_SUPABASE_ANON_KEY}",
|
||||
VITE_SUPABASE_PUBLISHABLE_KEY: "${VITE_SUPABASE_PUBLISHABLE_KEY}"
|
||||
};
|
||||
EOF
|
||||
exec nginx -g 'daemon off;'
|
||||
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import js from "@eslint/js"
|
||||
import globals from "globals"
|
||||
import reactHooks from "eslint-plugin-react-hooks"
|
||||
import reactRefresh from "eslint-plugin-react-refresh"
|
||||
import tseslint from "typescript-eslint"
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "src/routeTree.gen.ts", ".claude", ".opencode"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
19
index.html
Normal file
19
index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script src="/env.js"></script>
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EnglishAI — Luyện TOEIC thông minh</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT,WONK@0,9..144,300..700,0..100,0..1;1,9..144,300..700,0..100,0..1&family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
nginx.conf
Normal file
29
nginx.conf
Normal 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
8105
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
package.json
Normal file
45
package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "english-learning-app",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@supabase/supabase-js": "^2.103.0",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"@tanstack/react-router": "^1.168.16",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"shadcn": "^4.2.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/router-plugin": "^1.167.15",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"vite": "^8.0.8"
|
||||
}
|
||||
}
|
||||
1
public/env.js
Normal file
1
public/env.js
Normal file
@@ -0,0 +1 @@
|
||||
window.__ENV__ = {};
|
||||
37
src/components/CircularProgress.tsx
Normal file
37
src/components/CircularProgress.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
interface CircularProgressProps {
|
||||
percent: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
/** SVG circular progress ring with centered percentage label. */
|
||||
export function CircularProgress({
|
||||
percent,
|
||||
size = 44,
|
||||
strokeWidth = 3.5,
|
||||
color = '#2563EB',
|
||||
}: CircularProgressProps) {
|
||||
const cx = size / 2
|
||||
const radius = cx - strokeWidth
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const offset = circumference - (Math.min(percent, 100) / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center flex-shrink-0" style={{ width: size, height: size }}>
|
||||
<svg className="-rotate-90" width={size} height={size}>
|
||||
<circle cx={cx} cy={cx} r={radius} fill="none" stroke="#e2e8f0" strokeWidth={strokeWidth} />
|
||||
<circle
|
||||
cx={cx} cy={cx} r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-[10px] font-bold text-slate-700">{percent}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
src/components/ProgressBar.tsx
Normal file
26
src/components/ProgressBar.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
interface ProgressBarProps {
|
||||
value?: number
|
||||
max?: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function ProgressBar({ value = 0, max = 100, label }: ProgressBarProps) {
|
||||
const pct = Math.min(100, Math.round((value / max) * 100))
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>{label}</span>
|
||||
<span>{value}/{max}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-2 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
className="h-2 rounded-full bg-blue-500 transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
src/components/UserMenu.tsx
Normal file
113
src/components/UserMenu.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { useUser } from '@/hooks/use-auth'
|
||||
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||
|
||||
/** Avatar circle with first letter of name, deterministic color */
|
||||
function Avatar({ name }: { name: string }) {
|
||||
const colors = ['bg-blue-600', 'bg-green-600', 'bg-violet-600', 'bg-rose-600', 'bg-amber-600']
|
||||
const color = colors[name.charCodeAt(0) % colors.length]
|
||||
return (
|
||||
<div className={`w-8 h-8 rounded-full ${color} flex items-center justify-center text-white text-sm font-bold flex-shrink-0`}>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function UserMenu() {
|
||||
const user = useUser()
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const openModal = useAuthModalStore((s) => s.open)
|
||||
const navigate = useNavigate()
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!dropdownOpen) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [dropdownOpen])
|
||||
|
||||
async function handleLogout() {
|
||||
setDropdownOpen(false)
|
||||
await logout()
|
||||
navigate({ to: '/' })
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => openModal('login')}
|
||||
className="px-3.5 py-1.5 text-sm font-semibold text-slate-600 border border-slate-300 rounded-xl hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Đăng nhập
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openModal('register')}
|
||||
className="px-3.5 py-1.5 text-sm font-semibold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Đăng ký
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setDropdownOpen((o) => !o)}
|
||||
className="flex items-center gap-2 px-2 py-1 rounded-xl hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<Avatar name={user.name} />
|
||||
<span className="text-sm font-semibold text-slate-700 max-w-28 truncate hidden sm:block">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 16 }}>
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-52 bg-white rounded-2xl shadow-lg border border-slate-200 py-1.5 z-50">
|
||||
<div className="px-4 py-2 border-b border-slate-100 mb-1">
|
||||
<div className="text-sm font-semibold text-slate-800 truncate">{user.name}</div>
|
||||
<div className="text-xs text-slate-400 truncate">{user.email}</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { setDropdownOpen(false); alert('Coming soon!') }}
|
||||
className="w-full flex items-center gap-2.5 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 18 }}>history</span>
|
||||
Lịch sử bài thi
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setDropdownOpen(false); alert('Coming soon!') }}
|
||||
className="w-full flex items-center gap-2.5 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 18 }}>edit_note</span>
|
||||
Lịch sử writing
|
||||
</button>
|
||||
|
||||
<div className="border-t border-slate-100 mt-1 pt-1">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-2.5 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-red-400" style={{ fontSize: 18 }}>logout</span>
|
||||
Đăng xuất
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
src/components/layout/AppHeader.tsx
Normal file
81
src/components/layout/AppHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
src/components/layout/MobileNav.tsx
Normal file
42
src/components/layout/MobileNav.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', label: 'Home', icon: 'home', matchPrefix: '/', exact: true },
|
||||
{ to: '/archivement', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/archivement', exact: false },
|
||||
{ to: '/toeic', label: 'Luyện đề', icon: 'assignment', matchPrefix: '/toeic', exact: false },
|
||||
{ to: '/writing', label: 'Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false },
|
||||
{ to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false },
|
||||
]
|
||||
|
||||
function isActive(pathname: string, prefix: string, exact: boolean) {
|
||||
return exact ? pathname === prefix : pathname.startsWith(prefix)
|
||||
}
|
||||
|
||||
export function MobileNav() {
|
||||
const { location } = useRouterState()
|
||||
const pathname = location.pathname
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 inset-x-0 lg:hidden bg-white border-t border-slate-200 z-50 flex safe-area-inset-bottom">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = isActive(pathname, item.matchPrefix, item.exact)
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={cn(
|
||||
'flex-1 flex flex-col items-center justify-center gap-0.5 py-2 min-h-[56px] text-[11px] font-medium transition-colors',
|
||||
active ? 'text-blue-600' : 'text-slate-400',
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
|
||||
{item.icon}
|
||||
</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
137
src/components/layout/Sidebar.tsx
Normal file
137
src/components/layout/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
src/components/ui/button.tsx
Normal file
58
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
96
src/features/auth/components/AuthModal.tsx
Normal file
96
src/features/auth/components/AuthModal.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||
import { LoginForm } from './LoginForm'
|
||||
import { RegisterForm } from './RegisterForm'
|
||||
|
||||
export function AuthModal() {
|
||||
const { isOpen, mode, open, close } = useAuthModalStore()
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') close()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, close])
|
||||
|
||||
// Prevent body scroll when open
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = isOpen ? 'hidden' : ''
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [isOpen])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-60 flex items-center justify-center p-4"
|
||||
style={{ zIndex: 60 }}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={close}
|
||||
/>
|
||||
|
||||
{/* Card */}
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={close}
|
||||
className="absolute top-4 right-4 text-slate-400 hover:text-slate-600 transition-colors"
|
||||
aria-label="Đóng"
|
||||
>
|
||||
<span className="material-symbols-outlined text-xl">close</span>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="material-symbols-outlined text-blue-600">school</span>
|
||||
<span className="font-bold text-slate-800">TOEIC Luyện thi</span>
|
||||
</div>
|
||||
|
||||
{/* Tab toggle */}
|
||||
<div className="flex bg-slate-100 rounded-xl p-1">
|
||||
<button
|
||||
onClick={() => open('login')}
|
||||
className={`flex-1 py-2 text-sm font-semibold rounded-lg transition-all ${
|
||||
mode === 'login'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Đăng nhập
|
||||
</button>
|
||||
<button
|
||||
onClick={() => open('register')}
|
||||
className={`flex-1 py-2 text-sm font-semibold rounded-lg transition-all ${
|
||||
mode === 'register'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Đăng ký
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
{mode === 'login' ? (
|
||||
<LoginForm
|
||||
onSuccess={close}
|
||||
onSwitchToRegister={() => open('register')}
|
||||
/>
|
||||
) : (
|
||||
<RegisterForm
|
||||
onSuccess={close}
|
||||
onSwitchToLogin={() => open('login')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
src/features/auth/components/LoginForm.tsx
Normal file
82
src/features/auth/components/LoginForm.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
|
||||
interface LoginFormProps {
|
||||
onSuccess?: () => void
|
||||
onSwitchToRegister?: () => void
|
||||
}
|
||||
|
||||
export function LoginForm({ onSuccess, onSwitchToRegister }: LoginFormProps) {
|
||||
const login = useAuthStore((s) => s.login)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await login(email, password)
|
||||
onSuccess?.()
|
||||
} catch (err) {
|
||||
setError((err as Error).message)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Mật khẩu</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={6}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Tối thiểu 6 ký tự"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2.5 bg-blue-600 text-white text-sm font-semibold rounded-xl hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'Đang đăng nhập...' : 'Đăng nhập'}
|
||||
</button>
|
||||
|
||||
{onSwitchToRegister && (
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
Chưa có tài khoản?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToRegister}
|
||||
className="text-blue-600 font-medium hover:underline"
|
||||
>
|
||||
Đăng ký ngay
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
34
src/features/auth/components/LoginPage.tsx
Normal file
34
src/features/auth/components/LoginPage.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useUser } from '@/hooks/use-auth'
|
||||
import { LoginForm } from './LoginForm'
|
||||
|
||||
export function LoginPage() {
|
||||
const user = useUser()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (user) navigate({ to: '/' })
|
||||
}, [user, navigate])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-slate-50">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-2 mb-3">
|
||||
<span className="material-symbols-outlined text-blue-600 text-3xl">school</span>
|
||||
<span className="text-2xl font-bold text-slate-800">TOEIC Luyện thi</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-slate-700">Đăng nhập tài khoản</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||
<LoginForm
|
||||
onSuccess={() => navigate({ to: '/' })}
|
||||
onSwitchToRegister={() => navigate({ to: '/auth/register' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
94
src/features/auth/components/RegisterForm.tsx
Normal file
94
src/features/auth/components/RegisterForm.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
|
||||
interface RegisterFormProps {
|
||||
onSuccess?: () => void
|
||||
onSwitchToLogin?: () => void
|
||||
}
|
||||
|
||||
export function RegisterForm({ onSuccess, onSwitchToLogin }: RegisterFormProps) {
|
||||
const register = useAuthStore((s) => s.register)
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await register(name, email, password)
|
||||
onSuccess?.()
|
||||
} catch (err) {
|
||||
setError((err as Error).message)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Tên của bạn</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Nguyễn Văn A"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Mật khẩu</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={6}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Tối thiểu 6 ký tự"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2.5 bg-blue-600 text-white text-sm font-semibold rounded-xl hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'Đang đăng ký...' : 'Đăng ký'}
|
||||
</button>
|
||||
|
||||
{onSwitchToLogin && (
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
Đã có tài khoản?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToLogin}
|
||||
className="text-blue-600 font-medium hover:underline"
|
||||
>
|
||||
Đăng nhập
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
35
src/features/auth/components/RegisterPage.tsx
Normal file
35
src/features/auth/components/RegisterPage.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useUser } from '@/hooks/use-auth'
|
||||
import { RegisterForm } from './RegisterForm'
|
||||
|
||||
export function RegisterPage() {
|
||||
const user = useUser()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (user) navigate({ to: '/' })
|
||||
}, [user, navigate])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-slate-50">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-2 mb-3">
|
||||
<span className="material-symbols-outlined text-blue-600 text-3xl">school</span>
|
||||
<span className="text-2xl font-bold text-slate-800">TOEIC Luyện thi</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-slate-700">Tạo tài khoản miễn phí</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Không cần xác nhận email — dùng ngay lập tức</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||
<RegisterForm
|
||||
onSuccess={() => navigate({ to: '/' })}
|
||||
onSwitchToLogin={() => navigate({ to: '/auth/login' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
603
src/features/dashboard/components/Dashboard.tsx
Normal file
603
src/features/dashboard/components/Dashboard.tsx
Normal 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 và 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ố dư 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 có 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>
|
||||
)
|
||||
}
|
||||
154
src/features/flash-card/api/flashcard-api.ts
Normal file
154
src/features/flash-card/api/flashcard-api.ts
Normal 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)
|
||||
}
|
||||
59
src/features/flash-card/components/FlashCard.tsx
Normal file
59
src/features/flash-card/components/FlashCard.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FlashCardProps {
|
||||
word: string
|
||||
phonetic: string
|
||||
meaningVi: string
|
||||
example: string
|
||||
topicBadge: string
|
||||
isFlipped: boolean
|
||||
onFlip: () => void
|
||||
}
|
||||
|
||||
/** 3D flip flashcard. Front shows word/phonetic; back shows Vietnamese meaning + example. */
|
||||
export function FlashCard({ word, phonetic, meaningVi, example, topicBadge, isFlipped, onFlip }: FlashCardProps) {
|
||||
const highlightedExample = example.replace(
|
||||
new RegExp(`\\b${word}\\b`, 'gi'),
|
||||
(match) => `<strong>${match}</strong>`,
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flashcard-scene w-full cursor-pointer select-none"
|
||||
style={{ height: 280 }}
|
||||
onClick={onFlip}
|
||||
role="button"
|
||||
aria-label={isFlipped ? 'Nhấn để xem từ' : 'Nhấn để xem nghĩa'}
|
||||
>
|
||||
<div className={cn('flashcard-inner w-full h-full', isFlipped && 'is-flipped')}>
|
||||
{/* Front */}
|
||||
<div className="flashcard-face bg-white border border-slate-200 shadow-lg flex flex-col items-center justify-center p-8">
|
||||
<div className="text-4xl font-extrabold text-slate-800 mb-2">{word}</div>
|
||||
<div className="text-slate-400 text-lg mb-4">{phonetic}</div>
|
||||
<span className="bg-blue-50 text-blue-600 text-xs font-bold px-3 py-1 rounded-full">
|
||||
{topicBadge}
|
||||
</span>
|
||||
<p className="mt-6 text-xs text-slate-400 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>touch_app</span>
|
||||
Nhấn để xem nghĩa
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Back */}
|
||||
<div
|
||||
className="flashcard-face flashcard-back flex flex-col items-center justify-center p-8"
|
||||
style={{ background: 'linear-gradient(135deg, #eff6ff, #dbeafe)' }}
|
||||
>
|
||||
<div className="text-3xl font-extrabold text-blue-600 mb-1">{meaningVi}</div>
|
||||
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wide mb-4">
|
||||
Nghĩa tiếng Việt
|
||||
</div>
|
||||
<div
|
||||
className="bg-white rounded-xl p-3 border border-blue-100 text-sm text-slate-500 italic text-center"
|
||||
dangerouslySetInnerHTML={{ __html: `"${highlightedExample}"` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
604
src/features/flash-card/components/FlashCardLearnPage.tsx
Normal file
604
src/features/flash-card/components/FlashCardLearnPage.tsx
Normal 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 có 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>
|
||||
)
|
||||
}
|
||||
178
src/features/flash-card/components/FlashCardListPage.tsx
Normal file
178
src/features/flash-card/components/FlashCardListPage.tsx
Normal 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 có 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>
|
||||
)
|
||||
}
|
||||
249
src/features/flash-card/components/FlashCardTermsPage.tsx
Normal file
249
src/features/flash-card/components/FlashCardTermsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
248
src/features/flash-card/components/Vocabulary.tsx
Normal file
248
src/features/flash-card/components/Vocabulary.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FlashCard } from './FlashCard'
|
||||
import { useVocabStore } from '@/store/vocab-store'
|
||||
import { useVocab } from '@/hooks/use-vocab'
|
||||
import { VOCAB_TOPICS } from '@/types'
|
||||
import type { VocabTopic, VocabWord } from '@/types'
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||
|
||||
const GUEST_CARD_LIMIT = 5
|
||||
|
||||
export function Vocabulary() {
|
||||
const { currentTopic, currentIndex, knownWords, setTopic, setCurrentIndex, markKnown, markNeedReview } = useVocabStore()
|
||||
const [isFlipped, setIsFlipped] = useState(false)
|
||||
const { isAuthenticated, requireAuth } = useRequireAuth()
|
||||
|
||||
const { data: allVocab = [], isLoading, isError } = useVocab()
|
||||
const filtered: VocabWord[] = currentTopic === 'Tất cả'
|
||||
? allVocab
|
||||
: allVocab.filter((w) => w.topic === currentTopic)
|
||||
const safeIndex = Math.min(currentIndex, Math.max(0, filtered.length - 1))
|
||||
const word = filtered[safeIndex]
|
||||
const knownInFiltered = filtered.filter((w) => knownWords.includes(w.id)).length
|
||||
|
||||
function handleSetTopic(topic: VocabTopic) {
|
||||
setTopic(topic)
|
||||
setCurrentIndex(0)
|
||||
setIsFlipped(false)
|
||||
}
|
||||
|
||||
function handlePrev() {
|
||||
if (safeIndex > 0) {
|
||||
setCurrentIndex(safeIndex - 1)
|
||||
setIsFlipped(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (safeIndex < filtered.length - 1) {
|
||||
if (!isAuthenticated && safeIndex >= GUEST_CARD_LIMIT - 1) {
|
||||
requireAuth()
|
||||
return
|
||||
}
|
||||
setCurrentIndex(safeIndex + 1)
|
||||
setIsFlipped(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMarkKnown() {
|
||||
if (!isAuthenticated && safeIndex >= GUEST_CARD_LIMIT - 1) {
|
||||
requireAuth()
|
||||
return
|
||||
}
|
||||
if (word) markKnown(word.id)
|
||||
handleNext()
|
||||
}
|
||||
|
||||
function handleMarkReview() {
|
||||
if (!isAuthenticated && safeIndex >= GUEST_CARD_LIMIT - 1) {
|
||||
requireAuth()
|
||||
return
|
||||
}
|
||||
if (word) markNeedReview(word.id)
|
||||
handleNext()
|
||||
}
|
||||
|
||||
const recentKnown = knownWords
|
||||
.map((id) => allVocab.find((w) => w.id === id))
|
||||
.filter((w): w is VocabWord => w !== undefined)
|
||||
.slice(-5)
|
||||
.reverse()
|
||||
|
||||
return (
|
||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
||||
{/* Mobile topic chips */}
|
||||
<div className="lg:hidden mb-4 overflow-x-auto pb-1">
|
||||
<div className="flex gap-2 w-max">
|
||||
{VOCAB_TOPICS.map((topic) => (
|
||||
<button
|
||||
key={topic}
|
||||
onClick={() => handleSetTopic(topic)}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-full text-sm font-semibold whitespace-nowrap transition-colors',
|
||||
currentTopic === topic
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white border border-slate-200 text-slate-600 hover:border-blue-300',
|
||||
)}
|
||||
>
|
||||
{topic}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-5">
|
||||
{/* Left: Topic menu — desktop only */}
|
||||
<div className="hidden lg:block w-44 flex-shrink-0">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-3 sticky top-20">
|
||||
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider px-2 mb-2">Chủ đề</div>
|
||||
{VOCAB_TOPICS.map((topic) => (
|
||||
<button
|
||||
key={topic}
|
||||
onClick={() => handleSetTopic(topic)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-colors',
|
||||
currentTopic === topic
|
||||
? 'bg-blue-50 text-blue-600 font-semibold'
|
||||
: 'text-slate-600 hover:bg-slate-50',
|
||||
)}
|
||||
>
|
||||
{topic}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Flashcard */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{isLoading ? (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-10 flex flex-col items-center justify-center gap-3">
|
||||
<div className="w-8 h-8 border-2 border-blue-100 border-t-blue-600 rounded-full animate-spin" />
|
||||
<p className="text-sm text-slate-400">Đang tải từ vựng...</p>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="bg-red-50 rounded-2xl border border-red-100 p-10 text-center">
|
||||
<p className="text-sm text-red-500">Không thể tải từ vựng. Vui lòng thử lại.</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-10 text-center">
|
||||
<p className="text-slate-400">Không có từ vựng cho chủ đề này.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Progress */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-slate-400 font-medium">
|
||||
{safeIndex + 1} / {filtered.length} từ
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-blue-600">
|
||||
{knownInFiltered}/{filtered.length} đã thuộc
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-slate-200 mb-4">
|
||||
<div
|
||||
className="h-full bg-blue-600 rounded-full transition-all duration-300"
|
||||
style={{ width: `${filtered.length > 0 ? ((safeIndex + 1) / filtered.length) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{word && (
|
||||
<FlashCard
|
||||
word={word.word}
|
||||
phonetic={word.phonetic}
|
||||
meaningVi={word.meaningVi}
|
||||
example={word.example}
|
||||
topicBadge={word.topic}
|
||||
isFlipped={isFlipped}
|
||||
onFlip={() => setIsFlipped((v) => !v)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
disabled={safeIndex === 0}
|
||||
className="flex items-center gap-1.5 px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>chevron_left</span>
|
||||
Trước
|
||||
</button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleMarkReview}
|
||||
className="flex items-center gap-1.5 px-4 py-2.5 border border-amber-200 bg-amber-50 text-amber-700 rounded-xl text-sm font-semibold hover:bg-amber-100 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>replay</span>
|
||||
<span className="hidden sm:inline">Cần ôn</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMarkKnown}
|
||||
className="flex items-center gap-1.5 px-4 py-2.5 border border-green-200 bg-green-50 text-green-700 rounded-xl text-sm font-semibold hover:bg-green-100 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>check</span>
|
||||
<span className="hidden sm:inline">Đã thuộc</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={safeIndex === filtered.length - 1}
|
||||
className="flex items-center gap-1.5 px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Tiếp
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Stats — desktop only */}
|
||||
<div className="hidden lg:flex flex-col gap-4 w-52 flex-shrink-0">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider mb-3">Thống kê</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">Đã xem</span>
|
||||
<span className="text-sm font-bold text-slate-800">{safeIndex + 1}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">Đã thuộc</span>
|
||||
<span className="text-sm font-bold text-green-600">{knownWords.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">Tổng từ</span>
|
||||
<span className="text-sm font-bold text-blue-600">{allVocab.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-slate-100">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-amber-500" style={{ fontSize: 16 }}>local_fire_department</span>
|
||||
<span className="text-xs text-slate-500">Streak hôm nay</span>
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-amber-500 mt-1">{Math.min(safeIndex + 1, 99)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recentKnown.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider mb-3">Vừa thuộc</div>
|
||||
<div className="space-y-2">
|
||||
{recentKnown.map((w) => w && (
|
||||
<div key={w.id} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400 flex-shrink-0" />
|
||||
<span className="text-sm font-semibold text-slate-700 truncate">{w.word}</span>
|
||||
<span className="text-xs text-slate-400 truncate ml-auto">{w.meaningVi}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
src/features/flash-card/lib/media-url.ts
Normal file
7
src/features/flash-card/lib/media-url.ts
Normal 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}`}`
|
||||
}
|
||||
30
src/features/flash-card/lib/srs-intervals.ts
Normal file
30
src/features/flash-card/lib/srs-intervals.ts
Normal 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'
|
||||
}
|
||||
298
src/features/home/components/Home.tsx
Normal file
298
src/features/home/components/Home.tsx
Normal 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 ký để <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 ký 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>
|
||||
)
|
||||
}
|
||||
116
src/features/settings/components/AccountCard.tsx
Normal file
116
src/features/settings/components/AccountCard.tsx
Normal 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 & 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>
|
||||
)
|
||||
}
|
||||
56
src/features/settings/components/DailyGoalCard.tsx
Normal file
56
src/features/settings/components/DailyGoalCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
src/features/settings/components/ExamDateCard.tsx
Normal file
86
src/features/settings/components/ExamDateCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
101
src/features/settings/components/NotificationsCard.tsx
Normal file
101
src/features/settings/components/NotificationsCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
126
src/features/settings/components/ProfileCard.tsx
Normal file
126
src/features/settings/components/ProfileCard.tsx
Normal 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ồ sơ cá 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ọ và 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>
|
||||
)
|
||||
}
|
||||
49
src/features/settings/components/Settings.tsx
Normal file
49
src/features/settings/components/Settings.tsx
Normal 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 để cá nhân hoá mục tiêu học tập, cài đặt thông báo và quản lý 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 lý hồ sơ, mục tiêu học tập và thông báo.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-5">
|
||||
<ProfileCard />
|
||||
<XuWalletCard />
|
||||
<DailyGoalCard />
|
||||
<ExamDateCard />
|
||||
<NotificationsCard />
|
||||
<AccountCard />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
src/features/settings/components/XuWalletCard.tsx
Normal file
68
src/features/settings/components/XuWalletCard.tsx
Normal 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">Ví 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 có 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>
|
||||
)
|
||||
}
|
||||
53
src/features/toeic/api/test-list-api.ts
Normal file
53
src/features/toeic/api/test-list-api.ts
Normal 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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
29
src/features/toeic/components/QuestionCard.tsx
Normal file
29
src/features/toeic/components/QuestionCard.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
interface QuestionCardProps {
|
||||
question?: string
|
||||
options?: string[]
|
||||
selectedAnswer?: string
|
||||
onSelect?: (answer: string) => void
|
||||
}
|
||||
|
||||
export function QuestionCard({ question, options, selectedAnswer, onSelect }: QuestionCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<p className="font-medium">{question || "Question placeholder"}</p>
|
||||
{options && (
|
||||
<ul className="space-y-2">
|
||||
{options.map((opt) => (
|
||||
<li
|
||||
key={opt}
|
||||
onClick={() => onSelect?.(opt[0])}
|
||||
className={`rounded border p-2 text-sm cursor-pointer hover:bg-gray-50 ${
|
||||
selectedAnswer === opt[0] ? "border-blue-500 bg-blue-50" : ""
|
||||
}`}
|
||||
>
|
||||
{opt}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
183
src/features/toeic/components/TestResult.tsx
Normal file
183
src/features/toeic/components/TestResult.tsx
Normal 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 có 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>
|
||||
)
|
||||
}
|
||||
151
src/features/toeic/components/TestSession.tsx
Normal file
151
src/features/toeic/components/TestSession.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/features/toeic/components/TestSessionFooter.tsx
Normal file
36
src/features/toeic/components/TestSessionFooter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
src/features/toeic/components/TestSessionHeader.tsx
Normal file
43
src/features/toeic/components/TestSessionHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
src/features/toeic/components/TestSessionSidebar.tsx
Normal file
86
src/features/toeic/components/TestSessionSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
src/features/toeic/components/Timer.tsx
Normal file
14
src/features/toeic/components/Timer.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
interface TimerProps {
|
||||
seconds?: number
|
||||
}
|
||||
|
||||
export function Timer({ seconds = 0 }: TimerProps) {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
|
||||
return (
|
||||
<div className="font-mono text-lg font-semibold tabular-nums">
|
||||
{String(mins).padStart(2, "0")}:{String(secs).padStart(2, "0")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
src/features/toeic/components/ToeicPractice.tsx
Normal file
110
src/features/toeic/components/ToeicPractice.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { CircularProgress } from '@/components/CircularProgress'
|
||||
import { useTestStore } from '@/store/test-store'
|
||||
import { TOEIC_PARTS } from '@/temp/local-data'
|
||||
import { fetchQuestionsForTest } from '@/hooks/use-questions'
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||
|
||||
export function ToeicPractice() {
|
||||
const navigate = useNavigate()
|
||||
const { startExam } = useTestStore()
|
||||
const [loadingPartId, setLoadingPartId] = useState<number | null>(null)
|
||||
const { requireAuth } = useRequireAuth()
|
||||
|
||||
async function handleSelectPart(partId: number, partName: string) {
|
||||
if (!requireAuth()) return
|
||||
setLoadingPartId(partId)
|
||||
try {
|
||||
// TODO: replace hardcoded testId=1 with real test selection
|
||||
const parts = await fetchQuestionsForTest(1, [partId])
|
||||
startExam({ testId: 1, testName: partName, parts, totalSeconds: 0 })
|
||||
navigate({ to: '/toeic/session' })
|
||||
} catch (err) {
|
||||
console.error('Failed to load questions:', err)
|
||||
} finally {
|
||||
setLoadingPartId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 py-8 max-w-6xl mx-auto page-enter">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-extrabold text-slate-800 mb-2">Chọn Part TOEIC</h1>
|
||||
<p className="text-slate-500">
|
||||
Hệ thống ôn luyện theo cấu trúc bài thi TOEIC thực tế. Chọn phần cụ thể để bắt đầu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
{TOEIC_PARTS.map((part) => (
|
||||
<button
|
||||
key={part.id}
|
||||
onClick={() => handleSelectPart(part.id, part.nameVi)}
|
||||
disabled={loadingPartId !== null}
|
||||
className="bg-white rounded-2xl p-5 border border-slate-200 text-left hover:-translate-y-1 hover:shadow-md transition-all duration-200 group disabled:opacity-70 disabled:cursor-wait"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-5">
|
||||
<div className="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center group-hover:bg-blue-600 transition-colors">
|
||||
{loadingPartId === part.id ? (
|
||||
<span className="w-4 h-4 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin" />
|
||||
) : (
|
||||
<span
|
||||
className="material-symbols-outlined text-blue-600 group-hover:text-white transition-colors"
|
||||
style={{ fontSize: 18 }}
|
||||
>
|
||||
{part.icon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<CircularProgress percent={part.progressPercent} size={44} />
|
||||
</div>
|
||||
<div className="font-extrabold text-lg text-slate-800 mb-0.5">{part.name}</div>
|
||||
<div className="text-sm font-semibold text-slate-700 mb-2">{part.nameVi}</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>list_alt</span>
|
||||
{part.questionCount} câu hỏi
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Full Test card */}
|
||||
<button
|
||||
onClick={() => handleSelectPart(0, 'Full Test')}
|
||||
className="relative rounded-2xl p-5 text-left overflow-hidden hover:-translate-y-1 hover:shadow-xl transition-all duration-200"
|
||||
style={{ background: 'linear-gradient(135deg, #f59e0b, #d97706)' }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 opacity-10">
|
||||
<span className="material-symbols-outlined text-white" style={{ fontSize: 80 }}>
|
||||
workspace_premium
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center mb-5">
|
||||
<span className="material-symbols-outlined text-white" style={{ fontSize: 18 }}>
|
||||
military_tech
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-extrabold text-2xl text-white mb-0.5">Full Test</div>
|
||||
<div className="text-sm font-semibold text-amber-50 mb-2">Mô phỏng thi thật 2h</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-amber-100">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>timer</span>
|
||||
120 phút · 200 câu
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tip */}
|
||||
<div className="mt-8 bg-blue-50 border border-blue-100 rounded-2xl p-5 flex items-start gap-4">
|
||||
<span className="material-symbols-outlined text-blue-600 flex-shrink-0 mt-0.5">tips_and_updates</span>
|
||||
<div>
|
||||
<div className="font-semibold text-blue-700 text-sm mb-1">Mẹo luyện thi</div>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Bắt đầu từ <strong>Part 5 (Điền từ)</strong> — phần mang lại điểm nhanh nhất vì không phụ thuộc kỹ năng nghe. Mỗi ngày 20 câu, sau 2 tuần bạn sẽ thấy cải thiện rõ rệt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
151
src/features/toeic/components/ToeicTestDetail.tsx
Normal file
151
src/features/toeic/components/ToeicTestDetail.tsx
Normal 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">Mô 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>
|
||||
)
|
||||
}
|
||||
112
src/features/toeic/components/ToeicTestList.tsx
Normal file
112
src/features/toeic/components/ToeicTestList.tsx
Normal 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 có đề 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>
|
||||
)
|
||||
}
|
||||
416
src/features/writing/components/WritingChecker.tsx
Normal file
416
src/features/writing/components/WritingChecker.tsx
Normal 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ả, và 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 gì?</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> và 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>
|
||||
)
|
||||
}
|
||||
44
src/features/writing/components/WritingFeedback.tsx
Normal file
44
src/features/writing/components/WritingFeedback.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
interface WritingFeedbackProps {
|
||||
score?: string
|
||||
grammar?: string[]
|
||||
vocabulary?: string[]
|
||||
structure?: string
|
||||
summary?: string
|
||||
improvedVersion?: string
|
||||
}
|
||||
|
||||
export function WritingFeedback({ score, grammar, vocabulary, structure, summary }: WritingFeedbackProps) {
|
||||
if (!score) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">Band score:</span>
|
||||
<span className="text-lg font-bold text-blue-600">{score}</span>
|
||||
</div>
|
||||
{summary && <p className="text-sm text-gray-700">{summary}</p>}
|
||||
{grammar && grammar.length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium text-sm">Ngữ pháp:</p>
|
||||
<ul className="mt-1 space-y-1 text-sm text-gray-600 list-disc list-inside">
|
||||
{grammar.map((item, i) => <li key={i}>{item}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{vocabulary && vocabulary.length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium text-sm">Từ vựng:</p>
|
||||
<ul className="mt-1 space-y-1 text-sm text-gray-600 list-disc list-inside">
|
||||
{vocabulary.map((item, i) => <li key={i}>{item}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{structure && (
|
||||
<div>
|
||||
<p className="font-medium text-sm">Bố cục:</p>
|
||||
<p className="text-sm text-gray-600">{structure}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
134
src/features/writing/components/WritingHistory.tsx
Normal file
134
src/features/writing/components/WritingHistory.tsx
Normal 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 có bài nào được chấm.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!history?.length && (
|
||||
<div className="space-y-2">
|
||||
{history.map((item) => <SubmissionCard key={item.id} item={item} />)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
5
src/hooks/use-auth.ts
Normal file
5
src/hooks/use-auth.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
|
||||
export const useAuth = () => useAuthStore()
|
||||
export const useUser = () => useAuthStore((s) => s.user)
|
||||
export const useIsAuthenticated = () => useAuthStore((s) => s.user !== null)
|
||||
51
src/hooks/use-gamification.ts
Normal file
51
src/hooks/use-gamification.ts
Normal 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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
96
src/hooks/use-questions.ts
Normal file
96
src/hooks/use-questions.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { supabase } from "@/lib/supabase"
|
||||
import type { Question, SessionPart } from "@/types"
|
||||
|
||||
type AnswerChoiceRow = { value: string; label_text: string | null; is_correct: boolean }
|
||||
type QuestionRow = { id: number; question_text: string | null; explanation: string | null; group_id: number; answer_choice: AnswerChoiceRow[] }
|
||||
type GroupRow = { id: number; part_id: number; audio_url: string | null; image_url: string | null; passage_text: string | null }
|
||||
type PartRow = { id: number; part_number: number }
|
||||
|
||||
function buildOptions(choices: AnswerChoiceRow[]): string[] {
|
||||
return [...choices].sort((a, b) => a.value.localeCompare(b.value)).map(c => c.label_text ?? '')
|
||||
}
|
||||
|
||||
function getCorrectIndex(choices: AnswerChoiceRow[]): number {
|
||||
const sorted = [...choices].sort((a, b) => a.value.localeCompare(b.value))
|
||||
const idx = sorted.findIndex(c => c.is_correct)
|
||||
return idx >= 0 ? idx : 0
|
||||
}
|
||||
|
||||
function rowToQuestion(row: QuestionRow, group: GroupRow, partNumber: number): Question {
|
||||
return {
|
||||
id: row.id,
|
||||
partNumber,
|
||||
text: row.question_text,
|
||||
options: buildOptions(row.answer_choice),
|
||||
correctAnswer: getCorrectIndex(row.answer_choice),
|
||||
explanation: row.explanation,
|
||||
groupId: row.group_id,
|
||||
audioUrl: group.audio_url ?? undefined,
|
||||
imageUrl: group.image_url ?? undefined,
|
||||
passageText: group.passage_text ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all questions for a test, optionally filtered to specific part numbers.
|
||||
* partNumbers=[] or undefined → fetch all parts of the test.
|
||||
* Returns questions grouped into SessionPart[] ordered by part_number.
|
||||
*/
|
||||
export async function fetchQuestionsForTest(
|
||||
testId: number,
|
||||
partNumbers?: number[],
|
||||
): Promise<SessionPart[]> {
|
||||
// Step 1: Get parts for this test
|
||||
let partsQuery = supabase.from('part').select('id, part_number, title').eq('test_id', testId).order('part_number')
|
||||
if (partNumbers?.length) partsQuery = partsQuery.in('part_number', partNumbers)
|
||||
const { data: parts, error: partsError } = await partsQuery
|
||||
if (partsError) throw partsError
|
||||
if (!parts?.length) return []
|
||||
|
||||
const partRows = parts as (PartRow & { title: string })[]
|
||||
const partIds = partRows.map(p => p.id)
|
||||
const partNumberById = new Map(partRows.map(p => [p.id, p.part_number]))
|
||||
const partTitleByNumber = new Map(partRows.map(p => [p.part_number, p.title]))
|
||||
|
||||
// Step 2: Get question_groups for those parts
|
||||
const { data: groups, error: groupsError } = await supabase
|
||||
.from('question_group')
|
||||
.select('id, part_id, audio_url, image_url, passage_text')
|
||||
.in('part_id', partIds)
|
||||
if (groupsError) throw groupsError
|
||||
if (!groups?.length) return []
|
||||
|
||||
const groupMap = new Map<number, GroupRow>((groups as GroupRow[]).map(g => [g.id, g]))
|
||||
const groupIds = (groups as GroupRow[]).map(g => g.id)
|
||||
|
||||
// Step 3: Get questions with answer choices
|
||||
const { data: rows, error } = await supabase
|
||||
.from('question')
|
||||
.select('id, question_text, explanation, group_id, answer_choice(value, label_text, is_correct)')
|
||||
.in('group_id', groupIds)
|
||||
.order('question_number')
|
||||
if (error) throw error
|
||||
|
||||
const questions = (rows as QuestionRow[] ?? [])
|
||||
.map(row => {
|
||||
const group = groupMap.get(row.group_id)!
|
||||
const partNumber = partNumberById.get(group.part_id)!
|
||||
return rowToQuestion(row, group, partNumber)
|
||||
})
|
||||
.filter(q => q.options.length > 0)
|
||||
|
||||
// Group into SessionPart[] ordered by partNumber
|
||||
const byPart = new Map<number, Question[]>()
|
||||
for (const q of questions) {
|
||||
if (!byPart.has(q.partNumber)) byPart.set(q.partNumber, [])
|
||||
byPart.get(q.partNumber)!.push(q)
|
||||
}
|
||||
|
||||
return partRows
|
||||
.filter(p => byPart.has(p.part_number))
|
||||
.map(p => ({
|
||||
partNumber: p.part_number,
|
||||
partName: partTitleByNumber.get(p.part_number) ?? `Part ${p.part_number}`,
|
||||
questions: byPart.get(p.part_number)!,
|
||||
}))
|
||||
}
|
||||
17
src/hooks/use-require-auth.ts
Normal file
17
src/hooks/use-require-auth.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||
|
||||
export function useRequireAuth() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isLoading = useAuthStore((s) => s.isLoading)
|
||||
const openModal = useAuthModalStore((s) => s.open)
|
||||
|
||||
/** Returns true if authenticated. If guest, opens auth modal and returns false. */
|
||||
function requireAuth(): boolean {
|
||||
if (user) return true
|
||||
openModal('register')
|
||||
return false
|
||||
}
|
||||
|
||||
return { isAuthenticated: !!user, isLoading, requireAuth }
|
||||
}
|
||||
32
src/hooks/use-vocab.ts
Normal file
32
src/hooks/use-vocab.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
import type { VocabWord, VocabTopic } from "@/types"
|
||||
|
||||
// Maps a Supabase row to VocabWord.
|
||||
// DB column `meaning_vi` → interface field `meaningVi`.
|
||||
function rowToVocabWord(row: Record<string, unknown>): VocabWord {
|
||||
return {
|
||||
id: row.id as string,
|
||||
word: row.word as string,
|
||||
phonetic: (row.phonetic as string) ?? '',
|
||||
meaningVi: row.meaning_vi as string,
|
||||
topic: row.topic as VocabTopic,
|
||||
example: (row.example as string) ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// Fetches ALL vocab; topic filtering is done in-component so we avoid
|
||||
// separate queries per topic and keep the cache simple.
|
||||
export function useVocab() {
|
||||
return useQuery({
|
||||
queryKey: ['vocab'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('vocab')
|
||||
.select('*')
|
||||
.order('topic')
|
||||
if (error) throw error
|
||||
return (data ?? []).map(rowToVocabWord)
|
||||
},
|
||||
})
|
||||
}
|
||||
140
src/hooks/use-writing-check.ts
Normal file
140
src/hooks/use-writing-check.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { canUseWritingCheck, recordWritingCheckUsage } from "@/utils/rate-limiter"
|
||||
import { useAuthStore } from "@/store/auth-store"
|
||||
import { saveWritingSubmission, countTodayWritingSubmissions } from "@/lib/progress-service"
|
||||
import type { WritingFeedback } from "@/types"
|
||||
|
||||
const AUTH_DAILY_LIMIT = 10
|
||||
const GUEST_DAILY_LIMIT = 3
|
||||
|
||||
// Resolve env at runtime — production injects window.__ENV__ via docker/entrypoint.sh,
|
||||
// dev reads from Vite's import.meta.env. Must match src/lib/supabase.ts.
|
||||
function resolveSupabaseEnv() {
|
||||
const runtime = (window as unknown as { __ENV__?: Record<string, string> }).__ENV__ ?? {}
|
||||
const url = runtime.VITE_SUPABASE_URL || import.meta.env.VITE_SUPABASE_URL
|
||||
const key =
|
||||
runtime.VITE_SUPABASE_ANON_KEY ||
|
||||
runtime.VITE_SUPABASE_PUBLISHABLE_KEY ||
|
||||
import.meta.env.VITE_SUPABASE_ANON_KEY ||
|
||||
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
||||
return { url: url as string | undefined, key: key as string | undefined }
|
||||
}
|
||||
|
||||
// Calls the writing-check-dbiz Supabase edge function.
|
||||
// SSE format emitted by the function: data: {"text":"..."} | data: [DONE]
|
||||
async function callEdgeFunction(
|
||||
content: string,
|
||||
onChunk?: (text: string) => void,
|
||||
): Promise<WritingFeedback> {
|
||||
const { url, key } = resolveSupabaseEnv()
|
||||
if (!url || !key) {
|
||||
throw new Error("Supabase chưa được cấu hình. Vui lòng kiểm tra biến môi trường.")
|
||||
}
|
||||
const res = await fetch(`${url}/functions/v1/writing-check-dbiz`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${key}`,
|
||||
apikey: key,
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body?.error ?? "Đã có lỗi khi chấm bài. Vui lòng thử lại.")
|
||||
}
|
||||
|
||||
const reader = res.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
let accumulated = ""
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue
|
||||
const payload = line.slice(6).trim()
|
||||
if (payload === "[DONE]") continue
|
||||
|
||||
let chunk: { text?: string; error?: string }
|
||||
try {
|
||||
chunk = JSON.parse(payload)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
if (chunk.error) throw new Error(chunk.error)
|
||||
|
||||
const text = chunk.text ?? ""
|
||||
if (text) {
|
||||
accumulated += text
|
||||
onChunk?.(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const start = accumulated.indexOf("{")
|
||||
const end = accumulated.lastIndexOf("}")
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error("Phản hồi từ AI không hợp lệ. Vui lòng thử lại.")
|
||||
}
|
||||
|
||||
const raw = JSON.parse(accumulated.slice(start, end + 1))
|
||||
|
||||
const toArray = (v: unknown): string[] => {
|
||||
if (Array.isArray(v)) return v
|
||||
if (typeof v === "string" && v.length > 0) return [v]
|
||||
return []
|
||||
}
|
||||
|
||||
return {
|
||||
...raw,
|
||||
grammar: toArray(raw.grammar),
|
||||
vocabulary: toArray(raw.vocabulary),
|
||||
improvedVersion: raw.improved_version ?? raw.improvedVersion ?? "",
|
||||
} as WritingFeedback
|
||||
}
|
||||
|
||||
export function useWritingCheck() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
content,
|
||||
onChunk,
|
||||
}: {
|
||||
content: string
|
||||
onChunk?: (text: string) => void
|
||||
}): Promise<WritingFeedback> => {
|
||||
const user = useAuthStore.getState().user
|
||||
|
||||
if (user) {
|
||||
const usedToday = await countTodayWritingSubmissions(user.id)
|
||||
if (usedToday >= AUTH_DAILY_LIMIT) {
|
||||
throw new Error(`Bạn đã dùng hết ${AUTH_DAILY_LIMIT} lần kiểm tra hôm nay. Quay lại vào ngày mai!`)
|
||||
}
|
||||
} else {
|
||||
if (!canUseWritingCheck()) {
|
||||
throw new Error(`Bạn đã dùng hết ${GUEST_DAILY_LIMIT} lần kiểm tra hôm nay. Đăng ký để được 10 lần/ngày!`)
|
||||
}
|
||||
}
|
||||
|
||||
const feedback = await callEdgeFunction(content, onChunk)
|
||||
|
||||
if (user) {
|
||||
await saveWritingSubmission(user.id, content, feedback)
|
||||
queryClient.invalidateQueries({ queryKey: ["writing-history"] })
|
||||
} else {
|
||||
recordWritingCheckUsage()
|
||||
}
|
||||
|
||||
return feedback
|
||||
},
|
||||
})
|
||||
}
|
||||
14
src/hooks/use-writing-history.ts
Normal file
14
src/hooks/use-writing-history.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useAuthStore } from "@/store/auth-store"
|
||||
import { fetchWritingHistory } from "@/lib/progress-service"
|
||||
import type { WritingSubmission } from "@/types"
|
||||
|
||||
export function useWritingHistory() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
return useQuery<WritingSubmission[]>({
|
||||
queryKey: ["writing-history", user?.id],
|
||||
queryFn: () => fetchWritingHistory(user!.id) as Promise<WritingSubmission[]>,
|
||||
enabled: !!user,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
504
src/index.css
Normal file
504
src/index.css
Normal file
@@ -0,0 +1,504 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Plus Jakarta Sans', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Atelier palette (global — applied across entire app) */
|
||||
--at-brand: #3D4BD7;
|
||||
--at-brand-ink: #1A2280;
|
||||
--at-brand-soft: #E9ECFE;
|
||||
--at-brand-softer: #F4F5FE;
|
||||
--at-ink: #0F1114;
|
||||
--at-ink-2: #2A2D33;
|
||||
--at-ink-3: #3E4149;
|
||||
--at-mute: #6B6F76;
|
||||
--at-mute-2: #9CA0A8;
|
||||
--at-line: #E8E5DE;
|
||||
--at-line-2: #EFECE4;
|
||||
--at-paper: #FAF8F3;
|
||||
--at-paper-2: #F4F1EA;
|
||||
--at-surface: #FFFFFF;
|
||||
--at-good: #2F7D4A;
|
||||
--at-good-soft: #E4F0E7;
|
||||
--at-good-ink: #1B4B2C;
|
||||
--at-warm: #D26A3B;
|
||||
--at-warm-soft: #F8E9DE;
|
||||
--at-warm-ink: #6B2A14;
|
||||
--at-bad: #C1443E;
|
||||
--at-bad-soft: #F4DEDC;
|
||||
--at-streak: #C15A34;
|
||||
--at-streak-soft: #F7E6DC;
|
||||
|
||||
--at-serif: "Fraunces", "Instrument Serif", Georgia, serif;
|
||||
--at-sans: "Geist", "Geist Variable", "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
--at-mono: "Geist Mono", ui-monospace, SF Mono, Menlo, monospace;
|
||||
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(54.6% 0.245 262.3); /* #2563EB */
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
background: var(--at-paper);
|
||||
color: var(--at-ink);
|
||||
font-family: var(--at-sans);
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
/* Atelier global helpers — usable outside .atelier scope */
|
||||
.at-serif { font-family: var(--at-serif); }
|
||||
.at-mono { font-family: var(--at-mono); }
|
||||
.at-eyebrow {
|
||||
font-family: var(--at-serif);
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--at-mute);
|
||||
}
|
||||
.at-title {
|
||||
font-family: var(--at-serif);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.05;
|
||||
color: var(--at-ink);
|
||||
}
|
||||
.at-title i { font-style: italic; color: var(--at-brand); font-weight: 400; }
|
||||
.at-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--at-line-2);
|
||||
color: var(--at-ink-3);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.at-chip-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||
.at-chip-brand { background: var(--at-brand-soft); color: var(--at-brand-ink); }
|
||||
.at-chip-good { background: var(--at-good-soft); color: var(--at-good-ink); }
|
||||
.at-chip-warm { background: var(--at-warm-soft); color: var(--at-warm); }
|
||||
.at-bar {
|
||||
height: 6px;
|
||||
background: var(--at-line-2);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.at-bar > span {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: var(--at-brand);
|
||||
border-radius: 999px;
|
||||
transition: width 0.5s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
.at-pullquote {
|
||||
padding: 18px;
|
||||
border-radius: 16px;
|
||||
background: var(--at-brand-soft);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.at-pullquote-q {
|
||||
font-family: var(--at-serif);
|
||||
font-size: 15px;
|
||||
font-style: italic;
|
||||
line-height: 1.45;
|
||||
color: var(--at-brand-ink);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.at-tip {
|
||||
padding: 16px;
|
||||
background: var(--at-warm-soft);
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(210, 106, 59, 0.18);
|
||||
}
|
||||
.at-tip-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--at-warm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.at-tip-label::before {
|
||||
content: "";
|
||||
width: 5px; height: 5px; border-radius: 50%;
|
||||
background: var(--at-warm);
|
||||
}
|
||||
|
||||
/* ── Flashcard 3D flip ── */
|
||||
.flashcard-scene {
|
||||
perspective: 1000px;
|
||||
}
|
||||
.flashcard-inner {
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.flashcard-inner.is-flipped {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
.flashcard-face {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.flashcard-back {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
/* ── Material Symbols ── */
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── Page fade-in ── */
|
||||
@keyframes page-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.page-enter {
|
||||
animation: page-in 0.2s ease both;
|
||||
}
|
||||
|
||||
/* ── Timer urgent pulse ── */
|
||||
@keyframes timer-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
.timer-urgent {
|
||||
animation: timer-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────
|
||||
The Atelier — flashcard learn page scope
|
||||
Tokens + typography + 3D flip card
|
||||
Fonts: Fraunces + Geist + Geist Mono (loaded by route)
|
||||
──────────────────────────────────────────────────────────── */
|
||||
.atelier {
|
||||
--at-accent: #3D4BD7;
|
||||
--at-accent-soft: #E9ECFE;
|
||||
--at-accent-ink: #1A2280;
|
||||
--at-ink: #0F1114;
|
||||
--at-ink-2: #2A2D33;
|
||||
--at-mute: #6B6F76;
|
||||
--at-mute-2: #9CA0A8;
|
||||
--at-line: #E8E5DE;
|
||||
--at-line-2: #EFECE4;
|
||||
--at-paper: #FAF8F3;
|
||||
--at-paper-2: #F4F1EA;
|
||||
--at-good: #2F7D4A;
|
||||
--at-good-soft: #E4F0E7;
|
||||
--at-warm: #D26A3B;
|
||||
--at-warm-soft: #F8E9DE;
|
||||
|
||||
--at-serif: "Fraunces", "Instrument Serif", Georgia, serif;
|
||||
--at-sans: "Geist", "Geist Variable", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
--at-mono: "Geist Mono", ui-monospace, SF Mono, Menlo, monospace;
|
||||
|
||||
background: var(--at-paper);
|
||||
color: var(--at-ink);
|
||||
font-family: var(--at-sans);
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.atelier .at-serif { font-family: var(--at-serif); }
|
||||
.atelier .at-mono { font-family: var(--at-mono); }
|
||||
|
||||
/* Card */
|
||||
.atelier .at-card-outer {
|
||||
perspective: 2000px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
/* Size by available viewport height — never overflow */
|
||||
height: min(560px, calc(100vh - 14rem));
|
||||
max-height: 560px;
|
||||
}
|
||||
.atelier .at-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.75s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
animation: at-cardIn 0.5s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
@keyframes at-cardIn {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
.atelier .at-card.is-flipped { transform: rotateY(180deg); }
|
||||
.atelier .at-card-face {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
background: #fff;
|
||||
border-radius: 28px;
|
||||
padding: 28px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--at-line);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(15,17,20,0.04),
|
||||
0 20px 40px -16px rgba(15,17,20,0.12),
|
||||
0 4px 12px -4px rgba(15,17,20,0.06);
|
||||
}
|
||||
.atelier .at-card-back { transform: rotateY(180deg); }
|
||||
|
||||
.atelier .at-word {
|
||||
font-family: var(--at-serif);
|
||||
font-size: clamp(44px, 6vw, 72px);
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.035em;
|
||||
color: var(--at-ink);
|
||||
font-variation-settings: "opsz" 144, "SOFT" 30, "WONK" 1;
|
||||
}
|
||||
.atelier .at-meaning {
|
||||
font-family: var(--at-serif);
|
||||
font-size: 26px;
|
||||
font-weight: 400;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--at-ink);
|
||||
}
|
||||
|
||||
.atelier .at-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--at-accent-soft);
|
||||
color: var(--at-accent-ink);
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.atelier .at-chip-dot {
|
||||
width: 5px; height: 5px; border-radius: 50%;
|
||||
background: var(--at-accent);
|
||||
}
|
||||
.atelier .at-chip-mute { background: var(--at-line-2); color: var(--at-mute); }
|
||||
.atelier .at-chip-mute .at-chip-dot { background: var(--at-mute); }
|
||||
|
||||
.atelier .at-kbd {
|
||||
font-family: var(--at-mono);
|
||||
font-size: 10.5px;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid var(--at-line);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 5px;
|
||||
color: var(--at-ink-2);
|
||||
background: var(--at-paper-2);
|
||||
}
|
||||
|
||||
.atelier .at-example {
|
||||
padding: 14px 16px;
|
||||
background: var(--at-paper-2);
|
||||
border-radius: 12px;
|
||||
border-left: 2px solid var(--at-accent);
|
||||
}
|
||||
|
||||
.atelier .at-action {
|
||||
flex: 1;
|
||||
padding: 13px 18px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 13.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--at-line);
|
||||
background: #fff;
|
||||
color: var(--at-ink-2);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.atelier .at-action:hover:not(:disabled) { border-color: var(--at-ink); color: var(--at-ink); transform: translateY(-1px); }
|
||||
.atelier .at-action:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.atelier .at-action-known {
|
||||
background: var(--at-good);
|
||||
color: white;
|
||||
border-color: var(--at-good);
|
||||
}
|
||||
.atelier .at-action-known:hover:not(:disabled) { background: #236238; border-color: #236238; color: white; }
|
||||
.atelier .at-action-review {
|
||||
background: var(--at-warm-soft);
|
||||
color: var(--at-warm);
|
||||
border-color: rgba(210, 106, 59, 0.3);
|
||||
}
|
||||
.atelier .at-action-review:hover:not(:disabled) { background: var(--at-warm); color: white; border-color: var(--at-warm); }
|
||||
|
||||
.atelier .at-progress-bar {
|
||||
height: 6px;
|
||||
background: var(--at-line-2);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.atelier .at-progress-bar > span {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: linear-gradient(90deg, var(--at-accent), color-mix(in oklab, var(--at-accent) 80%, white));
|
||||
border-radius: 999px;
|
||||
transition: width 0.5s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
|
||||
.atelier .at-pct {
|
||||
font-family: var(--at-serif);
|
||||
font-size: 22px;
|
||||
color: var(--at-accent);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Swipe-off FX */
|
||||
@keyframes at-knownFx {
|
||||
0% { transform: translateY(0); }
|
||||
40% { transform: translateY(-8px) rotate(2deg); }
|
||||
100% { transform: translateX(120%) rotate(8deg); opacity: 0; }
|
||||
}
|
||||
@keyframes at-reviewFx {
|
||||
0% { transform: translateY(0); }
|
||||
40% { transform: translateY(-8px) rotate(-2deg); }
|
||||
100% { transform: translateX(-120%) rotate(-8deg); opacity: 0; }
|
||||
}
|
||||
.atelier .at-card.fx-known { animation: at-knownFx 0.55s cubic-bezier(0.4,0,0.2,1) forwards; }
|
||||
.atelier .at-card.fx-review { animation: at-reviewFx 0.55s cubic-bezier(0.4,0,0.2,1) forwards; }
|
||||
209
src/lib/gamification-service.ts
Normal file
209
src/lib/gamification-service.ts
Normal 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 }
|
||||
}
|
||||
95
src/lib/progress-service.ts
Normal file
95
src/lib/progress-service.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { supabase } from '@/lib/supabase'
|
||||
|
||||
const ANSWER_VALUES = ['A', 'B', 'C', 'D'] as const
|
||||
|
||||
interface TestResultData {
|
||||
testId: number | null
|
||||
selectedParts: number[]
|
||||
score: number
|
||||
total: number
|
||||
timeUsed: number
|
||||
answers: { questionId: number; selected: number | null; correct: boolean }[]
|
||||
}
|
||||
|
||||
/** Fire-and-forget: save test result. Failures are logged but don't block UI. */
|
||||
export async function saveTestResult(userId: string, data: TestResultData): Promise<void> {
|
||||
const { data: attempt, error: attemptError } = await supabase
|
||||
.from('user_test_attempt')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
test_id: data.testId,
|
||||
selected_parts: data.selectedParts,
|
||||
time_limit_minutes: 10,
|
||||
submitted_at: new Date().toISOString(),
|
||||
time_spent_seconds: data.timeUsed,
|
||||
total_correct: data.score,
|
||||
total_questions: data.total,
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (attemptError) {
|
||||
console.error('Failed to save test attempt:', attemptError.message)
|
||||
return
|
||||
}
|
||||
|
||||
const answerRows = data.answers.map(a => ({
|
||||
attempt_id: attempt.id,
|
||||
question_id: a.questionId,
|
||||
selected_value: a.selected !== null ? ANSWER_VALUES[a.selected] : null,
|
||||
is_correct: a.correct,
|
||||
}))
|
||||
|
||||
const { error: answersError } = await supabase.from('user_answer').insert(answerRows)
|
||||
if (answersError) console.error('Failed to save answers:', answersError.message)
|
||||
}
|
||||
|
||||
/** Fire-and-forget: save writing submission with AI feedback. */
|
||||
export async function saveWritingSubmission(
|
||||
userId: string,
|
||||
content: string,
|
||||
feedback: object,
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.from('writing_submissions').insert({
|
||||
user_id: userId,
|
||||
content,
|
||||
feedback,
|
||||
})
|
||||
if (error) console.error('Failed to save writing submission:', error.message)
|
||||
}
|
||||
|
||||
/** Count today's writing submissions for server-side rate limiting (authenticated users). */
|
||||
export async function countTodayWritingSubmissions(userId: string): Promise<number> {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const { count, error } = await supabase
|
||||
.from('writing_submissions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.gte('created_at', `${today}T00:00:00.000Z`)
|
||||
if (error) return 0
|
||||
return count ?? 0
|
||||
}
|
||||
|
||||
/** Fetch test history for a user (most recent first, max 20). */
|
||||
export async function fetchTestHistory(userId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('user_test_attempt')
|
||||
.select('id, selected_parts, time_spent_seconds, total_correct, total_questions, score, submitted_at, created_at')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20)
|
||||
if (error) throw error
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
/** Fetch writing history for a user (most recent first, max 20). */
|
||||
export async function fetchWritingHistory(userId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('writing_submissions')
|
||||
.select('id, content, feedback, created_at')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20)
|
||||
if (error) throw error
|
||||
return data ?? []
|
||||
}
|
||||
10
src/lib/query-client.ts
Normal file
10
src/lib/query-client.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { QueryClient } from "@tanstack/react-query"
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
25
src/lib/supabase.ts
Normal file
25
src/lib/supabase.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createClient } from "@supabase/supabase-js"
|
||||
|
||||
// Runtime env (injected by docker/entrypoint.sh) takes priority over build-time vars
|
||||
const runtimeEnv = (window as unknown as { __ENV__?: Record<string, string> }).__ENV__ ?? {}
|
||||
|
||||
const supabaseUrl =
|
||||
runtimeEnv.VITE_SUPABASE_URL ||
|
||||
import.meta.env.VITE_SUPABASE_URL
|
||||
|
||||
const supabaseAnonKey =
|
||||
runtimeEnv.VITE_SUPABASE_ANON_KEY ||
|
||||
runtimeEnv.VITE_SUPABASE_PUBLISHABLE_KEY ||
|
||||
import.meta.env.VITE_SUPABASE_ANON_KEY ||
|
||||
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
console.warn(
|
||||
"Supabase env vars missing. Set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY (or VITE_SUPABASE_PUBLISHABLE_KEY) in .env",
|
||||
)
|
||||
}
|
||||
|
||||
export const supabase = createClient(
|
||||
supabaseUrl || "https://placeholder.supabase.co",
|
||||
supabaseAnonKey || "placeholder-key",
|
||||
)
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
23
src/main.tsx
Normal file
23
src/main.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router"
|
||||
import { QueryClientProvider } from "@tanstack/react-query"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
import { queryClient } from "./lib/query-client"
|
||||
import "./index.css"
|
||||
|
||||
const router = createRouter({ routeTree })
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
31
src/routes/__root.tsx
Normal file
31
src/routes/__root.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createRootRoute, Outlet } from '@tanstack/react-router'
|
||||
import { useEffect } from 'react'
|
||||
import { Sidebar } from '@/components/layout/Sidebar'
|
||||
import { AppHeader } from '@/components/layout/AppHeader'
|
||||
import { MobileNav } from '@/components/layout/MobileNav'
|
||||
import { AuthModal } from '@/features/auth/components/AuthModal'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
})
|
||||
|
||||
function RootLayout() {
|
||||
const initialize = useAuthStore((s) => s.initialize)
|
||||
|
||||
useEffect(() => {
|
||||
initialize()
|
||||
}, [initialize])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Sidebar />
|
||||
<AppHeader />
|
||||
<main className="lg:ml-60 pt-16 pb-20 lg:pb-0 min-h-screen">
|
||||
<Outlet />
|
||||
</main>
|
||||
<MobileNav />
|
||||
<AuthModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
src/routes/archivement.tsx
Normal file
6
src/routes/archivement.tsx
Normal 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,
|
||||
})
|
||||
6
src/routes/auth.login.tsx
Normal file
6
src/routes/auth.login.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { LoginPage } from '@/features/auth/components/LoginPage'
|
||||
|
||||
export const Route = createFileRoute('/auth/login')({
|
||||
component: LoginPage,
|
||||
})
|
||||
6
src/routes/auth.register.tsx
Normal file
6
src/routes/auth.register.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { RegisterPage } from '@/features/auth/components/RegisterPage'
|
||||
|
||||
export const Route = createFileRoute('/auth/register')({
|
||||
component: RegisterPage,
|
||||
})
|
||||
11
src/routes/flash-card.$listId.index.tsx
Normal file
11
src/routes/flash-card.$listId.index.tsx
Normal 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)} />
|
||||
}
|
||||
11
src/routes/flash-card.$listId.learn.tsx
Normal file
11
src/routes/flash-card.$listId.learn.tsx
Normal 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)} />
|
||||
}
|
||||
5
src/routes/flash-card.$listId.tsx
Normal file
5
src/routes/flash-card.$listId.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/flash-card/$listId")({
|
||||
component: () => <Outlet />,
|
||||
})
|
||||
6
src/routes/flash-card.index.tsx
Normal file
6
src/routes/flash-card.index.tsx
Normal 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,
|
||||
})
|
||||
5
src/routes/flash-card.tsx
Normal file
5
src/routes/flash-card.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/flash-card")({
|
||||
component: () => <Outlet />,
|
||||
})
|
||||
6
src/routes/index.tsx
Normal file
6
src/routes/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Home } from "@/features/home/components/Home"
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: Home,
|
||||
})
|
||||
6
src/routes/settings.tsx
Normal file
6
src/routes/settings.tsx
Normal 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,
|
||||
})
|
||||
11
src/routes/toeic.$testId.tsx
Normal file
11
src/routes/toeic.$testId.tsx
Normal 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)} />
|
||||
}
|
||||
6
src/routes/toeic.index.tsx
Normal file
6
src/routes/toeic.index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { ToeicTestList } from "@/features/toeic/components/ToeicTestList"
|
||||
|
||||
export const Route = createFileRoute("/toeic/")({
|
||||
component: ToeicTestList,
|
||||
})
|
||||
15
src/routes/toeic.part.$partId.tsx
Normal file
15
src/routes/toeic.part.$partId.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/toeic/part/$partId")({
|
||||
component: ToeicPartConfig,
|
||||
})
|
||||
|
||||
function ToeicPartConfig() {
|
||||
const { partId } = Route.useParams()
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">TOEIC Part {partId}</h1>
|
||||
<p className="text-gray-500">Cấu hình bài thi — placeholder</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
src/routes/toeic.result.tsx
Normal file
6
src/routes/toeic.result.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { TestResult } from "@/features/toeic/components/TestResult"
|
||||
|
||||
export const Route = createFileRoute("/toeic/result")({
|
||||
component: TestResult,
|
||||
})
|
||||
6
src/routes/toeic.session.tsx
Normal file
6
src/routes/toeic.session.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { TestSession } from "@/features/toeic/components/TestSession"
|
||||
|
||||
export const Route = createFileRoute("/toeic/session")({
|
||||
component: TestSession,
|
||||
})
|
||||
9
src/routes/toeic.tsx
Normal file
9
src/routes/toeic.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/toeic")({
|
||||
component: ToeicLayout,
|
||||
})
|
||||
|
||||
function ToeicLayout() {
|
||||
return <Outlet />
|
||||
}
|
||||
16
src/routes/writing.tsx
Normal file
16
src/routes/writing.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { WritingChecker } from "@/features/writing/components/WritingChecker"
|
||||
import { WritingHistory } from "@/features/writing/components/WritingHistory"
|
||||
|
||||
function WritingPage() {
|
||||
return (
|
||||
<>
|
||||
<WritingChecker />
|
||||
<WritingHistory />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/writing")({
|
||||
component: WritingPage,
|
||||
})
|
||||
15
src/store/auth-modal-store.ts
Normal file
15
src/store/auth-modal-store.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface AuthModalState {
|
||||
isOpen: boolean
|
||||
mode: 'login' | 'register'
|
||||
open: (mode?: 'login' | 'register') => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export const useAuthModalStore = create<AuthModalState>((set) => ({
|
||||
isOpen: false,
|
||||
mode: 'register',
|
||||
open: (mode = 'register') => set({ isOpen: true, mode }),
|
||||
close: () => set({ isOpen: false }),
|
||||
}))
|
||||
71
src/store/auth-store.ts
Normal file
71
src/store/auth-store.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { create } from 'zustand'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import type { User } from '@/types'
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
isLoading: boolean
|
||||
login: (email: string, password: string) => Promise<void>
|
||||
register: (name: string, email: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
initialize: () => Promise<void>
|
||||
}
|
||||
|
||||
function sessionToUser(session: { user: { id: string; email?: string; user_metadata?: { name?: string } } } | null): User | null {
|
||||
if (!session) return null
|
||||
const { id, email, user_metadata } = session.user
|
||||
const name = user_metadata?.name ?? email?.split('@')[0] ?? 'Người dùng'
|
||||
return { id, email: email ?? '', name }
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
|
||||
initialize: async () => {
|
||||
set({ isLoading: true })
|
||||
|
||||
// Restore existing session (JWT in localStorage via Supabase SDK)
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
set({ user: sessionToUser(session), isLoading: false })
|
||||
|
||||
// Keep state in sync across tabs and token refresh
|
||||
supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||
set({ user: sessionToUser(newSession), isLoading: false })
|
||||
})
|
||||
},
|
||||
|
||||
login: async (email, password) => {
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password })
|
||||
if (error) throw new Error(mapAuthError(error.message))
|
||||
// onAuthStateChange fires and updates user state
|
||||
},
|
||||
|
||||
register: async (name, email, password) => {
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: { data: { name } },
|
||||
})
|
||||
if (error) throw new Error(mapAuthError(error.message))
|
||||
// onAuthStateChange fires and updates user state
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
await supabase.auth.signOut()
|
||||
// onAuthStateChange fires → user set to null
|
||||
},
|
||||
}))
|
||||
|
||||
function mapAuthError(msg: string): string {
|
||||
if (msg.includes('already registered') || msg.includes('already exists')) {
|
||||
return 'Email này đã được sử dụng. Vui lòng đăng nhập.'
|
||||
}
|
||||
if (msg.includes('Invalid login credentials') || msg.includes('invalid_credentials')) {
|
||||
return 'Sai email hoặc mật khẩu. Vui lòng kiểm tra lại.'
|
||||
}
|
||||
if (msg.includes('Email not confirmed')) {
|
||||
return 'Email chưa được xác nhận.'
|
||||
}
|
||||
return 'Đã có lỗi xảy ra. Vui lòng thử lại.'
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user