Compare commits
14 Commits
20ae176992
...
v1.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 3767fc92d9 | |||
| f233652acd | |||
| 285ab987fd | |||
| 309609fccb | |||
| 3e0b3f6a6d | |||
| 088c555515 | |||
| 4bc39225ab | |||
| 427557ef96 | |||
| 1736b8a68f | |||
| efd7fac42f | |||
| 01c5ccbd93 | |||
| 77a0e38fa7 | |||
| 409706457a | |||
| 406d7039d6 |
@@ -7,7 +7,10 @@ VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
|
||||
# Alternative key name (both are supported)
|
||||
# VITE_SUPABASE_ANON_KEY=eyJ...
|
||||
|
||||
# GLM API — https://open.bigmodel.cn/usercenter/apikeys
|
||||
# Used by the writing-check Supabase Edge Function (server-side only, never expose in frontend)
|
||||
# Deploy to Supabase with: supabase secrets set GLM_API_KEY=<your_key>
|
||||
# GLM API — used by writing-check edge function (server-side only)
|
||||
# Deploy with: supabase secrets set GLM_API_KEY=<your_key>
|
||||
GLM_API_KEY=your_glm_api_key_here
|
||||
|
||||
# DBIZ API — https://ai-api.dbiz.com
|
||||
# VITE_ prefix = exposed to browser (intentional, for direct streaming without edge function hop)
|
||||
VITE_DBIZ_API_KEY=your_dbiz_api_key_here
|
||||
|
||||
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
|
||||
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>
|
||||
5
.idea/material_theme_project_new.xml
generated
@@ -3,7 +3,10 @@
|
||||
<component name="MaterialThemeProjectNewConfig">
|
||||
<option name="metadata">
|
||||
<MTProjectMetadataState>
|
||||
<option name="userId" value="3d6f1b06:19d820f26b8:-7ffd" />
|
||||
<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>
|
||||
|
||||
22
Dockerfile
@@ -10,20 +10,10 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source
|
||||
# Copy source and build — no VITE_* args needed at build time.
|
||||
# Supabase keys are injected at runtime via docker/entrypoint.sh → window.__ENV__
|
||||
COPY . .
|
||||
|
||||
# VITE_* vars are Supabase public keys — safe for browser, baked into bundle at build time.
|
||||
# Using ARG only (no ENV) so values don't persist as image-layer env vars.
|
||||
ARG VITE_SUPABASE_URL
|
||||
ARG VITE_SUPABASE_ANON_KEY
|
||||
ARG VITE_SUPABASE_PUBLISHABLE_KEY
|
||||
|
||||
# Pass vars inline so they're scoped to this RUN layer only
|
||||
RUN VITE_SUPABASE_URL="$VITE_SUPABASE_URL" \
|
||||
VITE_SUPABASE_ANON_KEY="$VITE_SUPABASE_ANON_KEY" \
|
||||
VITE_SUPABASE_PUBLISHABLE_KEY="$VITE_SUPABASE_PUBLISHABLE_KEY" \
|
||||
npm run build
|
||||
RUN npm run build
|
||||
|
||||
# ============================================================
|
||||
# Stage 2 — Serve
|
||||
@@ -37,6 +27,10 @@ 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 ["nginx", "-g", "daemon off;"]
|
||||
CMD ["/entrypoint.sh"]
|
||||
|
||||
@@ -3,11 +3,11 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_SUPABASE_URL: ${VITE_SUPABASE_URL}
|
||||
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY}
|
||||
VITE_SUPABASE_PUBLISHABLE_KEY: ${VITE_SUPABASE_PUBLISHABLE_KEY}
|
||||
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
|
||||
|
||||
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;'
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script src="/env.js"></script>
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EnglishAI — Luyện TOEIC thông minh</title>
|
||||
@@ -9,6 +10,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT,WONK@0,9..144,300..700,0..100,0..1;1,9..144,300..700,0..100,0..1&family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -22,6 +22,7 @@ server {
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "ok";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
1
public/env.js
Normal file
@@ -0,0 +1 @@
|
||||
window.__ENV__ = {};
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useRouterState } from '@tanstack/react-router'
|
||||
import { useTestStore } from '@/store/test-store'
|
||||
import { UserMenu } from '@/components/UserMenu'
|
||||
|
||||
const ROUTE_TITLES: Record<string, string> = {
|
||||
'/': 'Trang chủ',
|
||||
'/writing': 'AI Chấm Writing',
|
||||
'/vocab': 'Từ vựng TOEIC',
|
||||
'/toeic': 'Luyện đề TOEIC',
|
||||
'/toeic/session': '', // dynamic — filled below
|
||||
'/toeic/result': 'Kết quả bài thi',
|
||||
}
|
||||
|
||||
export function AppHeader() {
|
||||
const { location } = useRouterState()
|
||||
const { partId, partName, answers, questions } = useTestStore()
|
||||
const pathname = location.pathname
|
||||
|
||||
let title = ROUTE_TITLES[pathname] ?? 'EnglishAI'
|
||||
|
||||
if (pathname === '/toeic/session') {
|
||||
const answered = answers.filter((a) => a !== null).length
|
||||
title = `Part ${partId} — ${partName} · ${answered}/${questions.length} câu`
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 right-0 left-0 lg:left-60 h-16 bg-white/90 backdrop-blur-md border-b border-slate-200 z-40 flex items-center justify-between px-6">
|
||||
<span className="text-sm font-semibold text-slate-700">{title}</span>
|
||||
<UserMenu />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', label: 'Trang chủ', icon: 'home', matchPrefix: '/', exact: true },
|
||||
{ to: '/dashboard', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/dashboard', 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: '/vocab', label: 'Từ vựng', icon: 'menu_book', matchPrefix: '/vocab', exact: false },
|
||||
{ to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false },
|
||||
]
|
||||
|
||||
function isActive(pathname: string, prefix: string, exact: boolean) {
|
||||
return exact ? pathname === prefix : pathname.startsWith(prefix)
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const { location } = useRouterState()
|
||||
const pathname = location.pathname
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const openModal = useAuthModalStore((s) => s.open)
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex fixed inset-y-0 left-0 w-60 flex-col bg-slate-50 border-r border-slate-200 z-50">
|
||||
{/* Brand */}
|
||||
<div className="px-6 py-5 border-b border-slate-200">
|
||||
<div className="text-xl font-extrabold text-blue-600 tracking-tight">EnglishAI</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">Học tập thông minh</div>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 py-3 overflow-y-auto">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = isActive(pathname, item.matchPrefix, item.exact)
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={cn(
|
||||
'flex items-center gap-3 mx-2 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150',
|
||||
active
|
||||
? 'bg-white text-blue-600 font-semibold shadow-sm'
|
||||
: 'text-slate-500 hover:bg-white/70 hover:text-slate-800',
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
|
||||
{item.icon}
|
||||
</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User */}
|
||||
<div className="px-3 py-4 border-t border-slate-200">
|
||||
{user ? (
|
||||
<div className="flex items-center gap-3 bg-white rounded-xl px-3 py-2.5">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-bold flex-shrink-0">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{user.name}</div>
|
||||
<div className="text-xs text-slate-400 truncate">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => openModal('login')}
|
||||
className="w-full flex items-center gap-3 bg-white rounded-xl px-3 py-2.5 hover:bg-blue-50 transition-colors group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center flex-shrink-0 group-hover:bg-blue-100 transition-colors">
|
||||
<span className="material-symbols-outlined text-slate-400 group-hover:text-blue-600 transition-colors" style={{ fontSize: 18 }}>person</span>
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<div className="text-sm font-semibold text-slate-600">Khách</div>
|
||||
<div className="text-xs text-blue-600 font-medium">Đăng nhập →</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', label: 'Home', icon: 'home', matchPrefix: '/', exact: true },
|
||||
{ to: '/dashboard', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/dashboard', exact: false },
|
||||
{ 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 },
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import type { Question, VocabWord, WritingFeedback, ToeicPart } from '@/types'
|
||||
|
||||
export const TOEIC_PARTS: ToeicPart[] = [
|
||||
{ id: 1, name: 'Part 1', nameVi: 'Mô tả hình ảnh', questionCount: 45, icon: 'image', progressPercent: 60 },
|
||||
{ id: 2, name: 'Part 2', nameVi: 'Hỏi-đáp', questionCount: 30, icon: 'question_answer', progressPercent: 40 },
|
||||
{ id: 3, name: 'Part 3', nameVi: 'Đoạn hội thoại', questionCount: 39, icon: 'forum', progressPercent: 25 },
|
||||
{ id: 4, name: 'Part 4', nameVi: 'Bài nói', questionCount: 30, icon: 'record_voice_over', progressPercent: 10 },
|
||||
{ id: 5, name: 'Part 5', nameVi: 'Điền từ', questionCount: 40, icon: 'history_edu', progressPercent: 80 },
|
||||
{ id: 6, name: 'Part 6', nameVi: 'Điền đoạn', questionCount: 16, icon: 'article', progressPercent: 50 },
|
||||
{ id: 7, name: 'Part 7', nameVi: 'Đọc hiểu', questionCount: 54, icon: 'chrome_reader_mode', progressPercent: 30 },
|
||||
]
|
||||
|
||||
export const MOCK_QUESTIONS: Question[] = [
|
||||
{
|
||||
id: 'q1', part: 2,
|
||||
text: 'What does the man suggest the woman do about the budget report?',
|
||||
options: ['A. Submit it immediately', 'B. Review it again carefully', 'C. Postpone the deadline', 'D. Ask a colleague for help'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'Người đàn ông nói "You should review it carefully before submitting" — gợi ý xem xét lại báo cáo trước khi nộp.',
|
||||
},
|
||||
{
|
||||
id: 'q2', part: 2,
|
||||
text: 'Where most likely are the speakers?',
|
||||
options: ['A. In a restaurant', 'B. At a conference', 'C. In an office', 'D. At an airport'],
|
||||
correctAnswer: 2,
|
||||
explanation: 'Các từ như "meeting room", "printer", "desk" cho biết cuộc trò chuyện diễn ra trong văn phòng.',
|
||||
},
|
||||
{
|
||||
id: 'q3', part: 2,
|
||||
text: 'Why is the man calling?',
|
||||
options: ['A. To confirm a reservation', 'B. To cancel an appointment', 'C. To reschedule a meeting', 'D. To order supplies'],
|
||||
correctAnswer: 0,
|
||||
explanation: 'Từ "confirm" và "booking number" trong hội thoại chỉ rõ mục đích của cuộc gọi là xác nhận đặt chỗ.',
|
||||
},
|
||||
{
|
||||
id: 'q4', part: 2,
|
||||
text: 'What will the woman do next?',
|
||||
options: ['A. Call the manager', 'B. Send an email', 'C. Check the inventory', 'D. Update the schedule'],
|
||||
correctAnswer: 3,
|
||||
explanation: 'Người phụ nữ nói "I\'ll update the schedule right away" — cho biết hành động tiếp theo là cập nhật lịch trình.',
|
||||
},
|
||||
{
|
||||
id: 'q5', part: 2,
|
||||
text: 'What problem does the man mention?',
|
||||
options: ['A. A delayed shipment', 'B. A broken device', 'C. A missing document', 'D. A scheduling conflict'],
|
||||
correctAnswer: 0,
|
||||
explanation: '"The delivery has been delayed by two days" — vấn đề được đề cập là lô hàng bị trễ.',
|
||||
},
|
||||
{
|
||||
id: 'q6', part: 2,
|
||||
text: 'How does the woman respond to the proposal?',
|
||||
options: ['A. She accepts it', 'B. She rejects it', 'C. She needs more time', 'D. She suggests modifications'],
|
||||
correctAnswer: 3,
|
||||
explanation: '"That sounds good, but maybe we could adjust the timeline a bit" — đề xuất điều chỉnh, không chấp nhận hoàn toàn.',
|
||||
},
|
||||
{
|
||||
id: 'q7', part: 2,
|
||||
text: 'What is the purpose of the announcement?',
|
||||
options: ['A. To introduce new products', 'B. To notify schedule changes', 'C. To welcome new employees', 'D. To announce a promotion'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'Thông báo nói về việc thay đổi giờ làm việc từ tuần tới — mục đích là thông báo thay đổi lịch.',
|
||||
},
|
||||
{
|
||||
id: 'q8', part: 2,
|
||||
text: 'What does the woman ask the man to do?',
|
||||
options: ['A. Prepare a presentation', 'B. Contact the client', 'C. Review the contract', 'D. Attend a training session'],
|
||||
correctAnswer: 2,
|
||||
explanation: '"Could you go over the contract before we sign?" — người phụ nữ yêu cầu xem lại hợp đồng.',
|
||||
},
|
||||
{
|
||||
id: 'q9', part: 2,
|
||||
text: 'When will the project be completed?',
|
||||
options: ['A. By the end of this week', 'B. Next Monday', 'C. In two weeks', 'D. Next month'],
|
||||
correctAnswer: 0,
|
||||
explanation: '"We should be finished by Friday" — dự án sẽ hoàn thành vào cuối tuần này.',
|
||||
},
|
||||
{
|
||||
id: 'q10', part: 2,
|
||||
text: 'What is being discussed at the meeting?',
|
||||
options: ['A. Budget allocations', 'B. Marketing strategies', 'C. Product launches', 'D. Staff promotions'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'Cuộc họp tập trung vào "the new advertising campaign and social media approach" — chiến lược marketing.',
|
||||
},
|
||||
]
|
||||
|
||||
export const VOCAB_DATA: Record<string, VocabWord[]> = {
|
||||
'Tất cả': [
|
||||
{ id: 'v1', word: 'negotiate', phonetic: '/nɪˈɡoʊʃieɪt/', meaningVi: 'đàm phán', topic: 'Business', example: 'We need to negotiate the contract terms before signing.' },
|
||||
{ id: 'v2', word: 'collaborate', phonetic: '/kəˈlæbəreɪt/', meaningVi: 'hợp tác', topic: 'Business', example: 'Teams need to collaborate effectively to meet the deadline.' },
|
||||
{ id: 'v3', word: 'agenda', phonetic: '/əˈdʒendə/', meaningVi: 'chương trình nghị sự', topic: 'Office', example: 'The agenda has been sent to all meeting participants.' },
|
||||
{ id: 'v4', word: 'itinerary', phonetic: '/aɪˈtɪnəreri/', meaningVi: 'lịch trình chuyến đi', topic: 'Travel', example: 'Here is your travel itinerary for the business conference.' },
|
||||
{ id: 'v5', word: 'reimburse', phonetic: '/ˌriːɪmˈbɜːrs/', meaningVi: 'hoàn tiền', topic: 'Finance', example: 'The company will reimburse your travel expenses.' },
|
||||
{ id: 'v6', word: 'recruit', phonetic: '/rɪˈkruːt/', meaningVi: 'tuyển dụng', topic: 'HR', example: 'We are actively recruiting experienced engineers.' },
|
||||
{ id: 'v7', word: 'campaign', phonetic: '/kæmˈpeɪn/', meaningVi: 'chiến dịch', topic: 'Marketing', example: 'The marketing campaign was very successful this quarter.' },
|
||||
{ id: 'v8', word: 'implement', phonetic: '/ˈɪmplɪment/', meaningVi: 'triển khai, thực hiện', topic: 'Business', example: 'We plan to implement the new strategy next quarter.' },
|
||||
],
|
||||
'Business': [
|
||||
{ id: 'b1', word: 'negotiate', phonetic: '/nɪˈɡoʊʃieɪt/', meaningVi: 'đàm phán', topic: 'Business', example: 'We need to negotiate the contract terms.' },
|
||||
{ id: 'b2', word: 'collaborate', phonetic: '/kəˈlæbəreɪt/', meaningVi: 'hợp tác', topic: 'Business', example: 'Teams collaborate to achieve shared goals.' },
|
||||
{ id: 'b3', word: 'delegate', phonetic: '/ˈdelɪɡeɪt/', meaningVi: 'uỷ quyền, phân công', topic: 'Business', example: 'A good manager knows how to delegate tasks.' },
|
||||
{ id: 'b4', word: 'implement', phonetic: '/ˈɪmplɪment/', meaningVi: 'triển khai, thực hiện', topic: 'Business', example: 'We will implement the new policy next month.' },
|
||||
{ id: 'b5', word: 'merger', phonetic: '/ˈmɜːrdʒər/', meaningVi: 'sáp nhập công ty', topic: 'Business', example: 'The merger will create a stronger combined company.' },
|
||||
{ id: 'b6', word: 'acquisition', phonetic: '/ˌækwɪˈzɪʃən/', meaningVi: 'mua lại, thâu tóm', topic: 'Business', example: 'The acquisition was completed ahead of schedule.' },
|
||||
],
|
||||
'Office': [
|
||||
{ id: 'o1', word: 'agenda', phonetic: '/əˈdʒendə/', meaningVi: 'chương trình nghị sự', topic: 'Office', example: 'Please review the agenda before the meeting.' },
|
||||
{ id: 'o2', word: 'minutes', phonetic: '/ˈmɪnɪts/', meaningVi: 'biên bản họp', topic: 'Office', example: 'Could you take the meeting minutes today?' },
|
||||
{ id: 'o3', word: 'submit', phonetic: '/səbˈmɪt/', meaningVi: 'nộp, gửi đi', topic: 'Office', example: 'Please submit your report by Friday afternoon.' },
|
||||
{ id: 'o4', word: 'deadline', phonetic: '/ˈdedlaɪn/', meaningVi: 'hạn chót', topic: 'Office', example: 'The deadline for this project is end of month.' },
|
||||
{ id: 'o5', word: 'cubicle', phonetic: '/ˈkjuːbɪkəl/', meaningVi: 'góc làm việc riêng', topic: 'Office', example: 'Each employee has their own cubicle in the open office.' },
|
||||
],
|
||||
'Travel': [
|
||||
{ id: 't1', word: 'itinerary', phonetic: '/aɪˈtɪnəreri/', meaningVi: 'lịch trình chuyến đi', topic: 'Travel', example: 'Here is your detailed travel itinerary.' },
|
||||
{ id: 't2', word: 'boarding pass', phonetic: '/ˈbɔːrdɪŋ pæs/', meaningVi: 'thẻ lên máy bay', topic: 'Travel', example: 'Please have your boarding pass ready at the gate.' },
|
||||
{ id: 't3', word: 'layover', phonetic: '/ˈleɪoʊvər/', meaningVi: 'thời gian quá cảnh', topic: 'Travel', example: 'There is a two-hour layover in Singapore.' },
|
||||
{ id: 't4', word: 'customs', phonetic: '/ˈkʌstəmz/', meaningVi: 'hải quan', topic: 'Travel', example: 'All passengers must go through customs on arrival.' },
|
||||
{ id: 't5', word: 'baggage claim', phonetic: '/ˈbæɡɪdʒ kleɪm/', meaningVi: 'băng chuyền hành lý', topic: 'Travel', example: 'Meet us at the baggage claim after landing.' },
|
||||
],
|
||||
'Finance': [
|
||||
{ id: 'f1', word: 'reimburse', phonetic: '/ˌriːɪmˈbɜːrs/', meaningVi: 'hoàn tiền', topic: 'Finance', example: 'The company will reimburse all travel expenses.' },
|
||||
{ id: 'f2', word: 'invoice', phonetic: '/ˈɪnvɔɪs/', meaningVi: 'hoá đơn', topic: 'Finance', example: 'Please send the invoice to our accounting department.' },
|
||||
{ id: 'f3', word: 'budget', phonetic: '/ˈbʌdʒɪt/', meaningVi: 'ngân sách', topic: 'Finance', example: 'We need to stay within the approved budget.' },
|
||||
{ id: 'f4', word: 'revenue', phonetic: '/ˈrevɪnjuː/', meaningVi: 'doanh thu', topic: 'Finance', example: 'Revenue increased by 15% last quarter.' },
|
||||
{ id: 'f5', word: 'fiscal year', phonetic: '/ˈfɪskəl jɪər/', meaningVi: 'năm tài chính', topic: 'Finance', example: 'Our fiscal year ends on December 31st.' },
|
||||
],
|
||||
'HR': [
|
||||
{ id: 'h1', word: 'recruit', phonetic: '/rɪˈkruːt/', meaningVi: 'tuyển dụng', topic: 'HR', example: 'We are recruiting experienced software engineers.' },
|
||||
{ id: 'h2', word: 'probation', phonetic: '/proʊˈbeɪʃən/', meaningVi: 'thử việc', topic: 'HR', example: 'New employees have a 3-month probation period.' },
|
||||
{ id: 'h3', word: 'appraisal', phonetic: '/əˈpreɪzəl/', meaningVi: 'đánh giá nhân viên', topic: 'HR', example: 'Annual performance appraisals are held in December.' },
|
||||
{ id: 'h4', word: 'resignation', phonetic: '/ˌrezɪɡˈneɪʃən/', meaningVi: 'đơn từ chức', topic: 'HR', example: 'She submitted her resignation letter this morning.' },
|
||||
{ id: 'h5', word: 'onboarding', phonetic: '/ˈɒnbɔːrdɪŋ/', meaningVi: 'quy trình tiếp nhận nhân viên mới', topic: 'HR', example: 'The onboarding process takes about two weeks.' },
|
||||
],
|
||||
'Marketing': [
|
||||
{ id: 'm1', word: 'campaign', phonetic: '/kæmˈpeɪn/', meaningVi: 'chiến dịch', topic: 'Marketing', example: 'The marketing campaign exceeded all expectations.' },
|
||||
{ id: 'm2', word: 'demographics', phonetic: '/ˌdeməˈɡræfɪks/', meaningVi: 'nhân khẩu học', topic: 'Marketing', example: 'We need to understand our target demographics.' },
|
||||
{ id: 'm3', word: 'endorse', phonetic: '/ɪnˈdɔːrs/', meaningVi: 'chứng thực, bảo trợ', topic: 'Marketing', example: 'The product is endorsed by professional athletes.' },
|
||||
{ id: 'm4', word: 'branding', phonetic: '/ˈbrændɪŋ/', meaningVi: 'xây dựng thương hiệu', topic: 'Marketing', example: 'Consistent branding builds long-term customer trust.' },
|
||||
{ id: 'm5', word: 'conversion rate', phonetic: '/kənˈvɜːrʒən reɪt/', meaningVi: 'tỷ lệ chuyển đổi', topic: 'Marketing', example: 'Our conversion rate improved after the redesign.' },
|
||||
],
|
||||
}
|
||||
|
||||
export const MOCK_WRITING_FEEDBACK: WritingFeedback = {
|
||||
score: '6.5',
|
||||
grammar: [
|
||||
'"managers are concern" → nên dùng "concerned" (tính từ, không phải danh từ)',
|
||||
'Thiếu mạo từ "an" trước "efficient arrangement" ở câu cuối',
|
||||
'Câu "This change is expected to improve" — đúng nhưng hơi thụ động, có thể dùng active voice',
|
||||
],
|
||||
vocabulary: [
|
||||
'Tốt: "implement", "productivity", "collaboration", "arrangement"',
|
||||
'Gợi ý nâng cao: "enhance" thay "increase", "address" thay "help with"',
|
||||
'Nên thêm từ nối: "Nevertheless", "In addition", "As a result of this"',
|
||||
],
|
||||
structure: 'Bài viết có cấu trúc khá rõ ràng với mở đầu, thân bài và kết luận ngầm. Tuy nhiên cần phát triển thêm phần giải thích tác động và thêm ví dụ cụ thể để bài hoàn chỉnh hơn.',
|
||||
improvedVersion: 'The company has decided to implement a new remote work policy starting next month. All employees will be able to work from home for three days per week. This change is expected to enhance work-life balance and boost overall productivity. Nevertheless, some managers are concerned about communication challenges and team collaboration. To address these concerns, the HR department will organize training sessions to help teams adapt to this new arrangement effectively.',
|
||||
summary: 'Bài viết đạt mức Upper Intermediate (6.5) với ý tưởng rõ ràng. Cần sửa lỗi ngữ pháp cơ bản và bổ sung từ vựng phong phú hơn để đạt band 7.0+.',
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useUser } from '@/hooks/use-auth'
|
||||
import { LoginForm } from '@/components/auth/LoginForm'
|
||||
import { LoginForm } from './LoginForm'
|
||||
|
||||
export function LoginPage() {
|
||||
const user = useUser()
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useUser } from '@/hooks/use-auth'
|
||||
import { RegisterForm } from '@/components/auth/RegisterForm'
|
||||
import { RegisterForm } from './RegisterForm'
|
||||
|
||||
export function RegisterPage() {
|
||||
const user = useUser()
|
||||
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
@@ -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)
|
||||
}
|
||||
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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FlashCard } from '@/components/FlashCard'
|
||||
import { FlashCard } from './FlashCard'
|
||||
import { useVocabStore } from '@/store/vocab-store'
|
||||
import { useVocab } from '@/hooks/use-vocab'
|
||||
import { VOCAB_TOPICS } from '@/types'
|
||||
@@ -170,7 +170,6 @@ export function Vocabulary() {
|
||||
Trước
|
||||
</button>
|
||||
|
||||
{/* Mark buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleMarkReview}
|
||||
@@ -203,7 +202,6 @@ export function Vocabulary() {
|
||||
|
||||
{/* Right: Stats — desktop only */}
|
||||
<div className="hidden lg:flex flex-col gap-4 w-52 flex-shrink-0">
|
||||
{/* Today stats */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider mb-3">Thống kê</div>
|
||||
<div className="space-y-3">
|
||||
@@ -229,7 +227,6 @@ export function Vocabulary() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recently known */}
|
||||
{recentKnown.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider mb-3">Vừa thuộc</div>
|
||||
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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||
import { ProfileCard } from './settings/ProfileCard'
|
||||
import { XuWalletCard } from './settings/XuWalletCard'
|
||||
import { DailyGoalCard } from './settings/DailyGoalCard'
|
||||
import { ExamDateCard } from './settings/ExamDateCard'
|
||||
import { NotificationsCard } from './settings/NotificationsCard'
|
||||
import { AccountCard } from './settings/AccountCard'
|
||||
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)
|
||||
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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { CircularProgress } from '@/components/CircularProgress'
|
||||
import { useTestStore } from '@/store/test-store'
|
||||
import { TOEIC_PARTS } from '@/data/mock-data'
|
||||
import { fetchQuestions } from '@/hooks/use-questions'
|
||||
import { TOEIC_PARTS } from '@/temp/local-data'
|
||||
import { fetchQuestionsForTest } from '@/hooks/use-questions'
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||
|
||||
export function ToeicPractice() {
|
||||
@@ -16,8 +16,9 @@ export function ToeicPractice() {
|
||||
if (!requireAuth()) return
|
||||
setLoadingPartId(partId)
|
||||
try {
|
||||
const questions = await fetchQuestions(partId, 10)
|
||||
startExam(partId, partName, questions)
|
||||
// TODO: replace hardcoded testId=1 with real test selection
|
||||
const parts = await fetchQuestionsForTest(1, [partId])
|
||||
startExam({ testId: 1, testName: partName, parts, totalSeconds: 0 })
|
||||
navigate({ to: '/toeic/session' })
|
||||
} catch (err) {
|
||||
console.error('Failed to load questions:', err)
|
||||
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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +1,96 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
import type { Question } from "@/types"
|
||||
import type { Question, SessionPart } from "@/types"
|
||||
|
||||
const ANSWER_INDEX: Record<string, number> = { A: 0, B: 1, C: 2, D: 3 }
|
||||
type AnswerChoiceRow = { value: string; label_text: string | null; is_correct: boolean }
|
||||
type QuestionRow = { id: number; question_text: string | null; explanation: string | null; group_id: number; answer_choice: AnswerChoiceRow[] }
|
||||
type GroupRow = { id: number; part_id: number; audio_url: string | null; image_url: string | null; passage_text: string | null }
|
||||
type PartRow = { id: number; part_number: number }
|
||||
|
||||
// Maps a Supabase row to the shared Question interface.
|
||||
// DB uses `content` + `answer` ('A'–'D'); interface uses `text` + `correctAnswer` (0–3).
|
||||
function rowToQuestion(row: Record<string, unknown>): Question {
|
||||
function buildOptions(choices: AnswerChoiceRow[]): string[] {
|
||||
return [...choices].sort((a, b) => a.value.localeCompare(b.value)).map(c => c.label_text ?? '')
|
||||
}
|
||||
|
||||
function getCorrectIndex(choices: AnswerChoiceRow[]): number {
|
||||
const sorted = [...choices].sort((a, b) => a.value.localeCompare(b.value))
|
||||
const idx = sorted.findIndex(c => c.is_correct)
|
||||
return idx >= 0 ? idx : 0
|
||||
}
|
||||
|
||||
function rowToQuestion(row: QuestionRow, group: GroupRow, partNumber: number): Question {
|
||||
return {
|
||||
id: row.id as string,
|
||||
part: row.part as number,
|
||||
text: row.content as string,
|
||||
options: row.options as string[],
|
||||
correctAnswer: ANSWER_INDEX[(row.answer as string).toUpperCase()] ?? 0,
|
||||
explanation: (row.explanation as string) ?? '',
|
||||
id: row.id,
|
||||
partNumber,
|
||||
text: row.question_text,
|
||||
options: buildOptions(row.answer_choice),
|
||||
correctAnswer: getCorrectIndex(row.answer_choice),
|
||||
explanation: row.explanation,
|
||||
groupId: row.group_id,
|
||||
audioUrl: group.audio_url ?? undefined,
|
||||
imageUrl: group.image_url ?? undefined,
|
||||
passageText: group.passage_text ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Exported for imperative use (e.g. ToeicPractice click handler).
|
||||
// part=0 fetches all parts (Full Test).
|
||||
export async function fetchQuestions(part: number, limit = 10): Promise<Question[]> {
|
||||
let query = supabase.from('questions').select('*').limit(limit)
|
||||
if (part > 0) query = query.eq('part', part)
|
||||
const { data, error } = await query
|
||||
if (error) throw error
|
||||
return (data ?? []).map(rowToQuestion)
|
||||
}
|
||||
/**
|
||||
* Fetch all questions for a test, optionally filtered to specific part numbers.
|
||||
* partNumbers=[] or undefined → fetch all parts of the test.
|
||||
* Returns questions grouped into SessionPart[] ordered by part_number.
|
||||
*/
|
||||
export async function fetchQuestionsForTest(
|
||||
testId: number,
|
||||
partNumbers?: number[],
|
||||
): Promise<SessionPart[]> {
|
||||
// Step 1: Get parts for this test
|
||||
let partsQuery = supabase.from('part').select('id, part_number, title').eq('test_id', testId).order('part_number')
|
||||
if (partNumbers?.length) partsQuery = partsQuery.in('part_number', partNumbers)
|
||||
const { data: parts, error: partsError } = await partsQuery
|
||||
if (partsError) throw partsError
|
||||
if (!parts?.length) return []
|
||||
|
||||
export function useQuestions(part: number, limit = 10) {
|
||||
return useQuery({
|
||||
queryKey: ['questions', part, limit],
|
||||
queryFn: () => fetchQuestions(part, limit),
|
||||
})
|
||||
const partRows = parts as (PartRow & { title: string })[]
|
||||
const partIds = partRows.map(p => p.id)
|
||||
const partNumberById = new Map(partRows.map(p => [p.id, p.part_number]))
|
||||
const partTitleByNumber = new Map(partRows.map(p => [p.part_number, p.title]))
|
||||
|
||||
// Step 2: Get question_groups for those parts
|
||||
const { data: groups, error: groupsError } = await supabase
|
||||
.from('question_group')
|
||||
.select('id, part_id, audio_url, image_url, passage_text')
|
||||
.in('part_id', partIds)
|
||||
if (groupsError) throw groupsError
|
||||
if (!groups?.length) return []
|
||||
|
||||
const groupMap = new Map<number, GroupRow>((groups as GroupRow[]).map(g => [g.id, g]))
|
||||
const groupIds = (groups as GroupRow[]).map(g => g.id)
|
||||
|
||||
// Step 3: Get questions with answer choices
|
||||
const { data: rows, error } = await supabase
|
||||
.from('question')
|
||||
.select('id, question_text, explanation, group_id, answer_choice(value, label_text, is_correct)')
|
||||
.in('group_id', groupIds)
|
||||
.order('question_number')
|
||||
if (error) throw error
|
||||
|
||||
const questions = (rows as QuestionRow[] ?? [])
|
||||
.map(row => {
|
||||
const group = groupMap.get(row.group_id)!
|
||||
const partNumber = partNumberById.get(group.part_id)!
|
||||
return rowToQuestion(row, group, partNumber)
|
||||
})
|
||||
.filter(q => q.options.length > 0)
|
||||
|
||||
// Group into SessionPart[] ordered by partNumber
|
||||
const byPart = new Map<number, Question[]>()
|
||||
for (const q of questions) {
|
||||
if (!byPart.has(q.partNumber)) byPart.set(q.partNumber, [])
|
||||
byPart.get(q.partNumber)!.push(q)
|
||||
}
|
||||
|
||||
return partRows
|
||||
.filter(p => byPart.has(p.part_number))
|
||||
.map(p => ({
|
||||
partNumber: p.part_number,
|
||||
partName: partTitleByNumber.get(p.part_number) ?? `Part ${p.part_number}`,
|
||||
questions: byPart.get(p.part_number)!,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,47 +1,135 @@
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { canUseWritingCheck, recordWritingCheckUsage } from "@/utils/rate-limiter"
|
||||
import { useAuthStore } from "@/store/auth-store"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
import { saveWritingSubmission, countTodayWritingSubmissions } from "@/lib/progress-service"
|
||||
import type { WritingFeedback } from "@/types"
|
||||
|
||||
const AUTH_DAILY_LIMIT = 10
|
||||
const GUEST_DAILY_LIMIT = 3
|
||||
|
||||
async function callEdgeFunction(content: string): Promise<WritingFeedback> {
|
||||
const { data, error } = await supabase.functions.invoke<WritingFeedback>("writing-check", {
|
||||
body: { content },
|
||||
// Resolve env at runtime — production injects window.__ENV__ via docker/entrypoint.sh,
|
||||
// dev reads from Vite's import.meta.env. Must match src/lib/supabase.ts.
|
||||
function resolveSupabaseEnv() {
|
||||
const runtime = (window as unknown as { __ENV__?: Record<string, string> }).__ENV__ ?? {}
|
||||
const url = runtime.VITE_SUPABASE_URL || import.meta.env.VITE_SUPABASE_URL
|
||||
const key =
|
||||
runtime.VITE_SUPABASE_ANON_KEY ||
|
||||
runtime.VITE_SUPABASE_PUBLISHABLE_KEY ||
|
||||
import.meta.env.VITE_SUPABASE_ANON_KEY ||
|
||||
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
||||
return { url: url as string | undefined, key: key as string | undefined }
|
||||
}
|
||||
|
||||
// Calls the writing-check-dbiz Supabase edge function.
|
||||
// SSE format emitted by the function: data: {"text":"..."} | data: [DONE]
|
||||
async function callEdgeFunction(
|
||||
content: string,
|
||||
onChunk?: (text: string) => void,
|
||||
): Promise<WritingFeedback> {
|
||||
const { url, key } = resolveSupabaseEnv()
|
||||
if (!url || !key) {
|
||||
throw new Error("Supabase chưa được cấu hình. Vui lòng kiểm tra biến môi trường.")
|
||||
}
|
||||
const res = await fetch(`${url}/functions/v1/writing-check-dbiz`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${key}`,
|
||||
apikey: key,
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
|
||||
if (error) throw new Error(error.message ?? "Đã có lỗi khi chấm bài. Vui lòng thử lại.")
|
||||
if (!data) throw new Error("Phản hồi từ AI không hợp lệ. Vui lòng thử lại.")
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body?.error ?? "Đã có lỗi khi chấm bài. Vui lòng thử lại.")
|
||||
}
|
||||
|
||||
return data
|
||||
const reader = res.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
let accumulated = ""
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue
|
||||
const payload = line.slice(6).trim()
|
||||
if (payload === "[DONE]") continue
|
||||
|
||||
let chunk: { text?: string; error?: string }
|
||||
try {
|
||||
chunk = JSON.parse(payload)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
if (chunk.error) throw new Error(chunk.error)
|
||||
|
||||
const text = chunk.text ?? ""
|
||||
if (text) {
|
||||
accumulated += text
|
||||
onChunk?.(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const start = accumulated.indexOf("{")
|
||||
const end = accumulated.lastIndexOf("}")
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error("Phản hồi từ AI không hợp lệ. Vui lòng thử lại.")
|
||||
}
|
||||
|
||||
const raw = JSON.parse(accumulated.slice(start, end + 1))
|
||||
|
||||
const toArray = (v: unknown): string[] => {
|
||||
if (Array.isArray(v)) return v
|
||||
if (typeof v === "string" && v.length > 0) return [v]
|
||||
return []
|
||||
}
|
||||
|
||||
return {
|
||||
...raw,
|
||||
grammar: toArray(raw.grammar),
|
||||
vocabulary: toArray(raw.vocabulary),
|
||||
improvedVersion: raw.improved_version ?? raw.improvedVersion ?? "",
|
||||
} as WritingFeedback
|
||||
}
|
||||
|
||||
export function useWritingCheck() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (content: string): Promise<WritingFeedback> => {
|
||||
mutationFn: async ({
|
||||
content,
|
||||
onChunk,
|
||||
}: {
|
||||
content: string
|
||||
onChunk?: (text: string) => void
|
||||
}): Promise<WritingFeedback> => {
|
||||
const user = useAuthStore.getState().user
|
||||
|
||||
if (user) {
|
||||
// Server-side rate limit for authenticated users (10/day)
|
||||
const usedToday = await countTodayWritingSubmissions(user.id)
|
||||
if (usedToday >= AUTH_DAILY_LIMIT) {
|
||||
throw new Error(`Bạn đã dùng hết ${AUTH_DAILY_LIMIT} lần kiểm tra hôm nay. Quay lại vào ngày mai!`)
|
||||
}
|
||||
} else {
|
||||
// localStorage rate limit for guests (3/day)
|
||||
if (!canUseWritingCheck()) {
|
||||
throw new Error(`Bạn đã dùng hết ${GUEST_DAILY_LIMIT} lần kiểm tra hôm nay. Đăng ký để được 10 lần/ngày!`)
|
||||
}
|
||||
}
|
||||
|
||||
const feedback = await callEdgeFunction(content)
|
||||
const feedback = await callEdgeFunction(content, onChunk)
|
||||
|
||||
if (user) {
|
||||
// Save submission to DB (fire-and-forget)
|
||||
saveWritingSubmission(user.id, content, feedback)
|
||||
await saveWritingSubmission(user.id, content, feedback)
|
||||
queryClient.invalidateQueries({ queryKey: ["writing-history"] })
|
||||
} else {
|
||||
recordWritingCheckUsage()
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
330
src/index.css
@@ -48,6 +48,36 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Atelier palette (global — applied across entire app) */
|
||||
--at-brand: #3D4BD7;
|
||||
--at-brand-ink: #1A2280;
|
||||
--at-brand-soft: #E9ECFE;
|
||||
--at-brand-softer: #F4F5FE;
|
||||
--at-ink: #0F1114;
|
||||
--at-ink-2: #2A2D33;
|
||||
--at-ink-3: #3E4149;
|
||||
--at-mute: #6B6F76;
|
||||
--at-mute-2: #9CA0A8;
|
||||
--at-line: #E8E5DE;
|
||||
--at-line-2: #EFECE4;
|
||||
--at-paper: #FAF8F3;
|
||||
--at-paper-2: #F4F1EA;
|
||||
--at-surface: #FFFFFF;
|
||||
--at-good: #2F7D4A;
|
||||
--at-good-soft: #E4F0E7;
|
||||
--at-good-ink: #1B4B2C;
|
||||
--at-warm: #D26A3B;
|
||||
--at-warm-soft: #F8E9DE;
|
||||
--at-warm-ink: #6B2A14;
|
||||
--at-bad: #C1443E;
|
||||
--at-bad-soft: #F4DEDC;
|
||||
--at-streak: #C15A34;
|
||||
--at-streak-soft: #F7E6DC;
|
||||
|
||||
--at-serif: "Fraunces", "Instrument Serif", Georgia, serif;
|
||||
--at-sans: "Geist", "Geist Variable", "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
--at-mono: "Geist Mono", ui-monospace, SF Mono, Menlo, monospace;
|
||||
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
@@ -121,13 +151,106 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-800;
|
||||
background: var(--at-paper);
|
||||
color: var(--at-ink);
|
||||
font-family: var(--at-sans);
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
/* Atelier global helpers — usable outside .atelier scope */
|
||||
.at-serif { font-family: var(--at-serif); }
|
||||
.at-mono { font-family: var(--at-mono); }
|
||||
.at-eyebrow {
|
||||
font-family: var(--at-serif);
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--at-mute);
|
||||
}
|
||||
.at-title {
|
||||
font-family: var(--at-serif);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.05;
|
||||
color: var(--at-ink);
|
||||
}
|
||||
.at-title i { font-style: italic; color: var(--at-brand); font-weight: 400; }
|
||||
.at-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--at-line-2);
|
||||
color: var(--at-ink-3);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.at-chip-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||
.at-chip-brand { background: var(--at-brand-soft); color: var(--at-brand-ink); }
|
||||
.at-chip-good { background: var(--at-good-soft); color: var(--at-good-ink); }
|
||||
.at-chip-warm { background: var(--at-warm-soft); color: var(--at-warm); }
|
||||
.at-bar {
|
||||
height: 6px;
|
||||
background: var(--at-line-2);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.at-bar > span {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: var(--at-brand);
|
||||
border-radius: 999px;
|
||||
transition: width 0.5s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
.at-pullquote {
|
||||
padding: 18px;
|
||||
border-radius: 16px;
|
||||
background: var(--at-brand-soft);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.at-pullquote-q {
|
||||
font-family: var(--at-serif);
|
||||
font-size: 15px;
|
||||
font-style: italic;
|
||||
line-height: 1.45;
|
||||
color: var(--at-brand-ink);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.at-tip {
|
||||
padding: 16px;
|
||||
background: var(--at-warm-soft);
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(210, 106, 59, 0.18);
|
||||
}
|
||||
.at-tip-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--at-warm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.at-tip-label::before {
|
||||
content: "";
|
||||
width: 5px; height: 5px; border-radius: 50%;
|
||||
background: var(--at-warm);
|
||||
}
|
||||
|
||||
/* ── Flashcard 3D flip ── */
|
||||
.flashcard-scene {
|
||||
perspective: 1000px;
|
||||
@@ -174,3 +297,208 @@
|
||||
.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; }
|
||||
@@ -1,22 +1,47 @@
|
||||
import { supabase } from '@/lib/supabase'
|
||||
|
||||
const ANSWER_VALUES = ['A', 'B', 'C', 'D'] as const
|
||||
|
||||
interface TestResultData {
|
||||
partId: number
|
||||
partName: string
|
||||
testId: number | null
|
||||
selectedParts: number[]
|
||||
score: number
|
||||
total: number
|
||||
timeUsed: number
|
||||
answers: { questionId: string; selected: number | null; correct: boolean }[]
|
||||
answers: { questionId: number; selected: number | null; correct: boolean }[]
|
||||
}
|
||||
|
||||
/** Fire-and-forget: save test result. Failures are logged but don't block UI. */
|
||||
export async function saveTestResult(userId: string, data: TestResultData): Promise<void> {
|
||||
const { error } = await supabase.from('user_progress').insert({
|
||||
user_id: userId,
|
||||
type: 'test',
|
||||
data,
|
||||
})
|
||||
if (error) console.error('Failed to save test result:', error.message)
|
||||
const { data: attempt, error: attemptError } = await supabase
|
||||
.from('user_test_attempt')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
test_id: data.testId,
|
||||
selected_parts: data.selectedParts,
|
||||
time_limit_minutes: 10,
|
||||
submitted_at: new Date().toISOString(),
|
||||
time_spent_seconds: data.timeUsed,
|
||||
total_correct: data.score,
|
||||
total_questions: data.total,
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (attemptError) {
|
||||
console.error('Failed to save test attempt:', attemptError.message)
|
||||
return
|
||||
}
|
||||
|
||||
const answerRows = data.answers.map(a => ({
|
||||
attempt_id: attempt.id,
|
||||
question_id: a.questionId,
|
||||
selected_value: a.selected !== null ? ANSWER_VALUES[a.selected] : null,
|
||||
is_correct: a.correct,
|
||||
}))
|
||||
|
||||
const { error: answersError } = await supabase.from('user_answer').insert(answerRows)
|
||||
if (answersError) console.error('Failed to save answers:', answersError.message)
|
||||
}
|
||||
|
||||
/** Fire-and-forget: save writing submission with AI feedback. */
|
||||
@@ -48,10 +73,9 @@ export async function countTodayWritingSubmissions(userId: string): Promise<numb
|
||||
/** Fetch test history for a user (most recent first, max 20). */
|
||||
export async function fetchTestHistory(userId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('user_progress')
|
||||
.select('*')
|
||||
.from('user_test_attempt')
|
||||
.select('id, selected_parts, time_spent_seconds, total_correct, total_questions, score, submitted_at, created_at')
|
||||
.eq('user_id', userId)
|
||||
.eq('type', 'test')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20)
|
||||
if (error) throw error
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { createClient } from "@supabase/supabase-js"
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
// Supports both key name conventions
|
||||
// Runtime env (injected by docker/entrypoint.sh) takes priority over build-time vars
|
||||
const runtimeEnv = (window as unknown as { __ENV__?: Record<string, string> }).__ENV__ ?? {}
|
||||
|
||||
const supabaseUrl =
|
||||
runtimeEnv.VITE_SUPABASE_URL ||
|
||||
import.meta.env.VITE_SUPABASE_URL
|
||||
|
||||
const supabaseAnonKey =
|
||||
runtimeEnv.VITE_SUPABASE_ANON_KEY ||
|
||||
runtimeEnv.VITE_SUPABASE_PUBLISHABLE_KEY ||
|
||||
import.meta.env.VITE_SUPABASE_ANON_KEY ||
|
||||
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
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'
|
||||
import { StatsRow } from './dashboard/StatsRow'
|
||||
import { XpProgressCard } from './dashboard/XpProgressCard'
|
||||
import { WeeklySection } from './dashboard/WeeklySection'
|
||||
import { XuEconomyCard } from './dashboard/XuEconomyCard'
|
||||
import { LeaderboardCard } from './dashboard/LeaderboardCard'
|
||||
|
||||
// Numeric level from XP (1 per 100 XP, min 1)
|
||||
export function calcNumericLevel(xp: number) {
|
||||
return Math.max(1, Math.floor(xp / 100))
|
||||
}
|
||||
|
||||
// XP needed for next numeric level
|
||||
export function calcXpNextLevel(xp: number) {
|
||||
return (Math.floor(xp / 100) + 1) * 100
|
||||
}
|
||||
|
||||
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-12 max-w-6xl mx-auto flex flex-col items-center text-center gap-4">
|
||||
<span className="material-symbols-outlined text-slate-300" style={{ fontSize: 64 }}>emoji_events</span>
|
||||
<h1 className="text-xl font-bold text-slate-700">Bảng thành tích</h1>
|
||||
<p className="text-slate-400 text-sm max-w-xs">
|
||||
Đă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 bg-blue-600 text-white rounded-full font-bold text-sm hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Đăng nhập
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Derive weekly completed from leaderboard XP
|
||||
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 xu = gam?.xu ?? 50
|
||||
const streak = gam?.streak ?? 0
|
||||
const xp = gam?.xp ?? 0
|
||||
const level = gam?.level ?? 'beginner'
|
||||
const lastActive = gam?.lastActive ?? null
|
||||
|
||||
return (
|
||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">Bảng thành tích</h1>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Xin chào, <span className="font-semibold text-slate-600">{user.name}</span> — tiếp tục chuỗi học tập nhé!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-white rounded-xl p-6 shadow-sm h-32 animate-pulse bg-slate-100" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<StatsRow xu={xu} streak={streak} xp={xp} level={level} />
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5 mb-5">
|
||||
<XpProgressCard xp={xp} />
|
||||
<WeeklySection streak={streak} lastActive={lastActive} weeklyCompleted={weeklyCompleted} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5">
|
||||
<XuEconomyCard />
|
||||
<LeaderboardCard />
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/toeic"
|
||||
className="fixed bottom-24 right-6 lg:bottom-8 lg:right-8 w-14 h-14 bg-blue-600 text-white rounded-2xl flex items-center justify-center shadow-2xl hover:scale-110 active:scale-95 transition-all z-40"
|
||||
title="Học ngay"
|
||||
>
|
||||
<span className="material-symbols-outlined text-2xl">play_arrow</span>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useUser } from '@/hooks/use-auth'
|
||||
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
to: '/toeic',
|
||||
icon: 'assignment',
|
||||
iconBg: 'bg-blue-50',
|
||||
iconColor: 'text-blue-600',
|
||||
borderColor: 'border-l-blue-600',
|
||||
title: 'Luyện đề TOEIC',
|
||||
desc: 'Kho đề thi cập nhật theo cấu trúc mới nhất. Phân tích điểm yếu chi tiết từng Part.',
|
||||
cta: 'Bắt đầu ngay',
|
||||
ctaColor: 'text-blue-600',
|
||||
stat: '350+ câu hỏi',
|
||||
},
|
||||
{
|
||||
to: '/writing',
|
||||
icon: 'auto_fix_high',
|
||||
iconBg: 'bg-green-50',
|
||||
iconColor: 'text-green-600',
|
||||
borderColor: 'border-l-green-600',
|
||||
title: 'AI Chấm Writing',
|
||||
desc: 'Phản hồi tức thì về ngữ pháp, từ vựng, cấu trúc và bài viết mẫu từ AI.',
|
||||
cta: 'Thử ngay',
|
||||
ctaColor: 'text-green-600',
|
||||
stat: '3 lượt / ngày',
|
||||
},
|
||||
{
|
||||
to: '/vocab',
|
||||
icon: 'menu_book',
|
||||
iconBg: 'bg-amber-50',
|
||||
iconColor: 'text-amber-600',
|
||||
borderColor: 'border-l-amber-600',
|
||||
title: 'Từ vựng thông minh',
|
||||
desc: '720 từ TOEIC theo 6 chủ đề. Flashcard với hiệu ứng lật 3D.',
|
||||
cta: 'Khám phá',
|
||||
ctaColor: 'text-amber-600',
|
||||
stat: '720 từ vựng',
|
||||
},
|
||||
]
|
||||
|
||||
export function Home() {
|
||||
const user = useUser()
|
||||
const openModal = useAuthModalStore((s) => s.open)
|
||||
|
||||
return (
|
||||
<div className="px-6 py-8 max-w-6xl mx-auto page-enter">
|
||||
{/* Hero */}
|
||||
<section className="flex flex-col lg:flex-row gap-10 items-center mb-12">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="inline-flex items-center gap-2 bg-blue-50 text-blue-600 text-xs font-bold px-3 py-1.5 rounded-full mb-5 uppercase tracking-wider">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>auto_awesome</span>
|
||||
AI-Powered Learning
|
||||
</div>
|
||||
<h1 className="text-4xl lg:text-5xl font-extrabold leading-tight text-slate-800 mb-4" style={{ letterSpacing: '-0.02em' }}>
|
||||
Luyện TOEIC<br />thông minh<br />
|
||||
<span className="text-blue-600 italic">cùng AI</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 text-lg leading-relaxed mb-8 max-w-md">
|
||||
Cá nhân hóa lộ trình học tập để bứt phá điểm số trong thời gian ngắn nhất. AI phân tích điểm yếu và tối ưu bài tập cho bạn.
|
||||
</p>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<Link
|
||||
to="/toeic"
|
||||
className="bg-blue-600 text-white px-8 py-3.5 rounded-xl font-bold text-sm hover:bg-blue-700 transition-colors shadow-lg shadow-blue-600/20"
|
||||
>
|
||||
Bắt đầu ngay
|
||||
</Link>
|
||||
<Link
|
||||
to="/writing"
|
||||
className="border border-slate-200 px-8 py-3.5 rounded-xl font-bold text-sm text-slate-500 hover:bg-white hover:border-blue-600 hover:text-blue-600 transition-all"
|
||||
>
|
||||
Thử AI Writing
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex gap-6 mt-8">
|
||||
<div>
|
||||
<div className="text-2xl font-extrabold text-blue-600">350+</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">Câu hỏi TOEIC</div>
|
||||
</div>
|
||||
<div className="w-px bg-slate-200" />
|
||||
<div>
|
||||
<div className="text-2xl font-extrabold text-green-600">720</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">Từ vựng</div>
|
||||
</div>
|
||||
<div className="w-px bg-slate-200" />
|
||||
<div>
|
||||
<div className="text-2xl font-extrabold text-amber-600">AI</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">Writing Checker</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview card — hidden on mobile */}
|
||||
<div className="hidden lg:block flex-shrink-0 w-80">
|
||||
<div className="bg-white rounded-2xl p-6 shadow-xl border border-slate-100">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div className="font-bold text-base text-slate-800">Tiến độ tuần này</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">Bạn đang làm rất tốt!</div>
|
||||
</div>
|
||||
<div className="bg-green-50 text-green-600 text-xs font-bold px-2.5 py-1 rounded-lg">+12%</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-xs font-semibold mb-1.5">
|
||||
<span>Reading Score</span><span className="text-blue-600">420/495</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-slate-100">
|
||||
<div className="h-full bg-blue-600 rounded-full" style={{ width: '85%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-xs font-semibold mb-1.5">
|
||||
<span>Listening Score</span><span className="text-green-600">380/495</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-slate-100">
|
||||
<div className="h-full bg-green-600 rounded-full" style={{ width: '77%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||
<div className="bg-blue-50 rounded-xl p-3 border-l-4 border-blue-600">
|
||||
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 18 }}>local_fire_department</span>
|
||||
<div className="text-xl font-extrabold text-blue-600 mt-1">14</div>
|
||||
<div className="text-xs text-slate-400">Ngày Streak</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-xl p-3 border-l-4 border-green-600">
|
||||
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 18, fontVariationSettings: "'FILL' 1" }}>star</span>
|
||||
<div className="text-xl font-extrabold text-green-600 mt-1">1,250</div>
|
||||
<div className="text-xs text-slate-400">Điểm tích lũy</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-slate-100 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center flex-shrink-0">
|
||||
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 16 }}>psychology</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
<span className="font-semibold">AI gợi ý:</span> Ôn thêm Part 5 — Ngữ pháp
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature cards */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-extrabold text-slate-800 mb-1.5">Tính năng nổi bật</h2>
|
||||
<p className="text-slate-500 mb-6">Hệ sinh thái học tập toàn diện được thiết kế để tối ưu hoá điểm số.</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{FEATURES.map((f) => (
|
||||
<Link
|
||||
key={f.to}
|
||||
to={f.to}
|
||||
className={`bg-white rounded-2xl p-6 border border-slate-200 border-l-4 ${f.borderColor} hover:-translate-y-1 hover:shadow-md transition-all duration-200`}
|
||||
>
|
||||
<div className={`w-12 h-12 ${f.iconBg} rounded-xl flex items-center justify-center mb-4`}>
|
||||
<span className={`material-symbols-outlined ${f.iconColor}`}>{f.icon}</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-base text-slate-800 mb-2">{f.title}</h3>
|
||||
<p className="text-slate-500 text-sm leading-relaxed mb-4">{f.desc}</p>
|
||||
<div className={`flex items-center gap-1.5 text-sm font-bold ${f.ctaColor}`}>
|
||||
{f.cta}
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>arrow_forward</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA banner */}
|
||||
<section className="mt-10">
|
||||
<div className="bg-blue-600 rounded-2xl p-8 flex items-center justify-between overflow-hidden relative">
|
||||
<div className="absolute right-4 top-0 bottom-0 flex items-center opacity-10">
|
||||
<span className="material-symbols-outlined text-white" style={{ fontSize: 120 }}>emoji_events</span>
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<h3 className="text-2xl font-extrabold text-white mb-2">Sẵn sàng chinh phục 990 TOEIC?</h3>
|
||||
<p className="text-blue-100 mb-5">
|
||||
{user
|
||||
? `Chào ${user.name}! Tiếp tục luyện thi hôm nay.`
|
||||
: 'Đăng ký miễn phí để lưu tiến độ và luyện thi không giới hạn.'}
|
||||
</p>
|
||||
{user ? (
|
||||
<Link
|
||||
to="/toeic"
|
||||
className="inline-block bg-white text-blue-600 px-6 py-3 rounded-xl font-bold text-sm hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
Luyện thi ngay
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => openModal('register')}
|
||||
className="bg-white text-blue-600 px-6 py-3 rounded-xl font-bold text-sm hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
Đăng ký miễn phí
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTestStore } from '@/store/test-store'
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { saveTestResult } from '@/lib/progress-service'
|
||||
import { useAwardActivity } from '@/hooks/use-gamification'
|
||||
import { XP_REWARDS } from '@/lib/gamification-service'
|
||||
|
||||
function formatTime(s: number) {
|
||||
const m = Math.floor(s / 60)
|
||||
const sec = s % 60
|
||||
if (m === 0) return `${sec}s`
|
||||
return `${m}m ${sec}s`
|
||||
}
|
||||
|
||||
export function TestResult() {
|
||||
const navigate = useNavigate()
|
||||
const { partId, partName, questions, answers, timeUsed, reset } = useTestStore()
|
||||
const { isAuthenticated, isLoading } = useRequireAuth()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const savedRef = useRef(false)
|
||||
const { mutate: awardActivity } = useAwardActivity()
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
if (!isAuthenticated) navigate({ to: '/toeic' })
|
||||
}, [isLoading, isAuthenticated, navigate])
|
||||
|
||||
// Save test result once when page mounts (fire-and-forget)
|
||||
useEffect(() => {
|
||||
if (!user || savedRef.current || questions.length === 0) return
|
||||
savedRef.current = true
|
||||
awardActivity({ xp: XP_REWARDS.test })
|
||||
saveTestResult(user.id, {
|
||||
partId,
|
||||
partName,
|
||||
score: answers.filter((a, i) => a === questions[i]?.correctAnswer).length,
|
||||
total: questions.length,
|
||||
timeUsed,
|
||||
answers: questions.map((q, i) => ({
|
||||
questionId: q.id,
|
||||
selected: answers[i],
|
||||
correct: answers[i] === q.correctAnswer,
|
||||
})),
|
||||
})
|
||||
}, [user, questions, answers, partId, partName, timeUsed])
|
||||
|
||||
const correct = answers.filter((a, i) => a === questions[i]?.correctAnswer).length
|
||||
const wrong = answers.filter((a, i) => a !== null && a !== questions[i]?.correctAnswer).length
|
||||
const skipped = answers.filter((a) => a === null).length
|
||||
const total = questions.length
|
||||
const percent = total > 0 ? Math.round((correct / total) * 100) : 0
|
||||
|
||||
const circumference = 2 * Math.PI * 52
|
||||
const offset = circumference - (percent / 100) * circumference
|
||||
|
||||
function handleRetry() {
|
||||
navigate({ to: '/toeic/session' })
|
||||
}
|
||||
|
||||
function handleHome() {
|
||||
reset()
|
||||
navigate({ to: '/' })
|
||||
}
|
||||
|
||||
if (questions.length === 0) {
|
||||
return (
|
||||
<div className="px-6 py-8 max-w-6xl mx-auto text-center">
|
||||
<p className="text-slate-500 mb-4">Không 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 Part để luyện thi
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
||||
{/* Score header */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 mb-5">
|
||||
<div className="flex flex-col lg:flex-row items-center gap-6">
|
||||
{/* Circle */}
|
||||
<div className="flex-shrink-0 relative w-32 h-32">
|
||||
<svg className="w-full h-full -rotate-90" viewBox="0 0 120 120">
|
||||
<circle cx="60" cy="60" r="52" fill="none" stroke="#e2e8f0" strokeWidth="8" />
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke={percent >= 70 ? '#16a34a' : percent >= 50 ? '#2563eb' : '#dc2626'}
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
className="transition-all duration-700"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-extrabold text-slate-800">{correct}/{total}</span>
|
||||
<span className="text-xs text-slate-400 font-medium">điểm</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex-1 text-center lg:text-left">
|
||||
<div className="text-2xl font-extrabold text-slate-800 mb-1">
|
||||
{percent >= 80 ? 'Xuất sắc!' : percent >= 60 ? 'Hoàn thành!' : 'Cố gắng hơn nhé!'}
|
||||
</div>
|
||||
<div className="text-sm text-slate-400 mb-4">
|
||||
Part {partId} — {partName}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 justify-center lg:justify-start">
|
||||
<div className="bg-green-50 border border-green-100 rounded-xl px-4 py-2 text-center">
|
||||
<div className="text-xl font-extrabold text-green-600">{correct}</div>
|
||||
<div className="text-xs text-slate-400">Đúng</div>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-100 rounded-xl px-4 py-2 text-center">
|
||||
<div className="text-xl font-extrabold text-red-600">{wrong}</div>
|
||||
<div className="text-xs text-slate-400">Sai</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl px-4 py-2 text-center">
|
||||
<div className="text-xl font-extrabold text-slate-500">{skipped}</div>
|
||||
<div className="text-xs text-slate-400">Bỏ qua</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-xl px-4 py-2 text-center">
|
||||
<div className="text-xl font-extrabold text-blue-600">{formatTime(timeUsed)}</div>
|
||||
<div className="text-xs text-slate-400">Thời gian</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex lg:flex-col gap-3 flex-shrink-0">
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>replay</span>
|
||||
Làm lại
|
||||
</button>
|
||||
<button
|
||||
onClick={handleHome}
|
||||
className="flex items-center gap-2 px-5 py-2.5 border border-slate-200 text-slate-600 rounded-xl text-sm font-semibold hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>home</span>
|
||||
Về trang chủ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Answer review */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-6">
|
||||
<h2 className="text-base font-bold text-slate-800 mb-4">Xem lại đáp án</h2>
|
||||
<div className="space-y-4">
|
||||
{questions.map((q, i) => {
|
||||
const userAnswer = answers[i]
|
||||
const isCorrect = userAnswer === q.correctAnswer
|
||||
const isSkipped = userAnswer === null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={q.id}
|
||||
className={cn(
|
||||
'rounded-xl border p-4',
|
||||
isCorrect ? 'border-green-100 bg-green-50/50' : isSkipped ? 'border-slate-100 bg-slate-50/50' : 'border-red-100 bg-red-50/50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5',
|
||||
isCorrect ? 'bg-green-600 text-white' : isSkipped ? 'bg-slate-400 text-white' : 'bg-red-600 text-white',
|
||||
)}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 mb-2">{q.text}</p>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{q.options.map((opt, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1 rounded-lg font-medium',
|
||||
j === q.correctAnswer
|
||||
? 'bg-green-100 text-green-700 border border-green-200'
|
||||
: j === userAnswer && !isCorrect
|
||||
? 'bg-red-100 text-red-700 border border-red-200 line-through'
|
||||
: 'bg-slate-100 text-slate-500',
|
||||
)}
|
||||
>
|
||||
{['A', 'B', 'C', 'D'][j]}. {opt}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{q.explanation && (
|
||||
<p className="text-xs text-slate-500 bg-white rounded-lg px-3 py-2 border border-slate-100">
|
||||
<span className="font-semibold text-slate-600">Giải thích: </span>
|
||||
{q.explanation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex-shrink-0">
|
||||
{isCorrect ? (
|
||||
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 20 }}>check_circle</span>
|
||||
) : isSkipped ? (
|
||||
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 20 }}>remove_circle</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-red-500" style={{ fontSize: 20 }}>cancel</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTestStore } from '@/store/test-store'
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||
|
||||
const TOTAL_SECONDS = 600 // 10 minutes
|
||||
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
|
||||
|
||||
function formatTime(s: number) {
|
||||
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function TestSession() {
|
||||
const navigate = useNavigate()
|
||||
const { partId, partName, questions, answers, setAnswer, submitExam } = useTestStore()
|
||||
const [currentQ, setCurrentQ] = useState(0)
|
||||
const [timeLeft, setTimeLeft] = useState(TOTAL_SECONDS)
|
||||
const { isAuthenticated, isLoading } = useRequireAuth()
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
submitExam(TOTAL_SECONDS - timeLeft)
|
||||
navigate({ to: '/toeic/result' })
|
||||
}, [submitExam, navigate, timeLeft])
|
||||
|
||||
// Countdown
|
||||
useEffect(() => {
|
||||
if (questions.length === 0) return
|
||||
const id = setInterval(() => {
|
||||
setTimeLeft((t) => {
|
||||
if (t <= 1) { clearInterval(id); handleSubmit(); return 0 }
|
||||
return t - 1
|
||||
})
|
||||
}, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [questions.length, handleSubmit])
|
||||
|
||||
// Redirect if no exam started or not authenticated (wait for auth init)
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
if (!isAuthenticated || questions.length === 0) navigate({ to: '/toeic' })
|
||||
}, [isLoading, isAuthenticated, questions.length, navigate])
|
||||
|
||||
if (questions.length === 0) return null
|
||||
|
||||
const question = questions[currentQ]
|
||||
const answeredCount = answers.filter((a) => a !== null).length
|
||||
const isUrgent = timeLeft < 60
|
||||
|
||||
return (
|
||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
||||
{/* Mobile progress bar */}
|
||||
<div className="lg:hidden mb-4">
|
||||
<div className="flex justify-between text-sm font-semibold mb-2">
|
||||
<span className="text-slate-700">Part {partId} — Câu {currentQ + 1}/{questions.length}</span>
|
||||
<span className={cn('font-bold tabular-nums', isUrgent ? 'text-red-600 timer-urgent' : 'text-blue-600')}>
|
||||
{formatTime(timeLeft)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-slate-200">
|
||||
<div
|
||||
className="h-full bg-blue-600 rounded-full transition-all"
|
||||
style={{ width: `${((currentQ + 1) / questions.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-5">
|
||||
{/* Left: Question */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 mb-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="bg-blue-600 text-white text-xs font-bold px-3 py-1 rounded-full">
|
||||
Câu {currentQ + 1}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">Part {partId} — {partName}</span>
|
||||
</div>
|
||||
<p className="text-base font-medium text-slate-800 leading-relaxed mb-6">
|
||||
{question.text}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{question.options.map((opt, i) => {
|
||||
const selected = answers[currentQ] === i
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setAnswer(currentQ, i)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 p-4 border-2 rounded-xl text-sm font-medium text-left transition-all',
|
||||
selected
|
||||
? 'border-blue-600 bg-blue-50 text-blue-700'
|
||||
: 'border-slate-200 hover:border-blue-300 hover:bg-blue-50/50 text-slate-700',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0',
|
||||
selected ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-500',
|
||||
)}
|
||||
>
|
||||
{ANSWER_LABELS[i]}
|
||||
</span>
|
||||
{opt}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setCurrentQ((q) => Math.max(0, q - 1))}
|
||||
disabled={currentQ === 0}
|
||||
className="flex items-center gap-2 px-5 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>chevron_left</span>
|
||||
Câu trước
|
||||
</button>
|
||||
<span className="text-xs text-slate-400 tabular-nums">{currentQ + 1} / {questions.length}</span>
|
||||
{currentQ < questions.length - 1 ? (
|
||||
<button
|
||||
onClick={() => setCurrentQ((q) => q + 1)}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Câu tiếp theo
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>chevron_right</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-red-600 text-white rounded-xl text-sm font-semibold hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Nộp bài
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>send</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel — desktop only */}
|
||||
<div className="hidden lg:flex flex-col gap-4 w-60 flex-shrink-0">
|
||||
{/* Timer */}
|
||||
<div className="bg-white rounded-2xl p-5 border border-slate-200 text-center">
|
||||
<div className="text-xs text-slate-400 font-medium mb-2">Thời gian còn lại</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-5xl font-extrabold tabular-nums mb-1',
|
||||
isUrgent ? 'text-red-600 timer-urgent' : 'text-blue-600',
|
||||
)}
|
||||
>
|
||||
{formatTime(timeLeft)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">phút : giây</div>
|
||||
</div>
|
||||
|
||||
{/* Question dots */}
|
||||
<div className="bg-white rounded-2xl p-5 border border-slate-200">
|
||||
<div className="text-xs text-slate-400 font-medium mb-3">
|
||||
Danh sách câu · {answeredCount}/{questions.length} đã trả lời
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{questions.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrentQ(i)}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-[11px] font-semibold transition-all',
|
||||
i === currentQ
|
||||
? 'border-2 border-blue-600 text-blue-600 shadow-sm shadow-blue-600/20'
|
||||
: answers[i] !== null
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'border-2 border-slate-200 text-slate-400 hover:border-blue-300',
|
||||
)}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-4 text-xs text-slate-400">
|
||||
<span className="w-4 h-4 rounded-full bg-blue-600 inline-block" /> Đã trả lời
|
||||
<span className="w-4 h-4 rounded-full border-2 border-slate-200 inline-block" /> Chưa làm
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="w-full py-3 bg-red-600 text-white rounded-xl font-bold text-sm hover:bg-red-700 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>send</span>
|
||||
Nộp bài
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile submit */}
|
||||
<div className="lg:hidden mt-4">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="w-full py-3.5 bg-red-600 text-white rounded-xl font-bold text-sm hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Nộp bài ngay
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useWritingCheck } from '@/hooks/use-writing-check'
|
||||
import { getRemainingChecks } from '@/utils/rate-limiter'
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { countTodayWritingSubmissions } from '@/lib/progress-service'
|
||||
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
|
||||
|
||||
export function WritingChecker() {
|
||||
const [text, setText] = useState('')
|
||||
const [improvedExpanded, setImprovedExpanded] = useState(false)
|
||||
const [remaining, setRemaining] = useState(getRemainingChecks)
|
||||
|
||||
const { mutate: checkWriting, isPending, isError, error, data: feedback } = useWritingCheck()
|
||||
const { requireAuth } = useRequireAuth()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const { mutate: awardActivity } = useAwardActivity()
|
||||
|
||||
const dailyLimit = user ? AUTH_LIMIT : GUEST_LIMIT
|
||||
|
||||
// Fetch server-side remaining count for authenticated users
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setRemaining(getRemainingChecks())
|
||||
return
|
||||
}
|
||||
countTodayWritingSubmissions(user.id).then((used) => {
|
||||
setRemaining(AUTH_LIMIT - used)
|
||||
})
|
||||
}, [user])
|
||||
|
||||
const charCount = text.length
|
||||
const canSubmit = text.trim().length > 0 && remaining > 0 && charCount <= MAX_CHARS && !isPending
|
||||
|
||||
function handleSubmit() {
|
||||
if (!requireAuth()) return
|
||||
if (!canSubmit) return
|
||||
checkWriting(text, {
|
||||
onSuccess: () => {
|
||||
if (user) {
|
||||
awardActivity({ xp: XP_REWARDS.writing })
|
||||
countTodayWritingSubmissions(user.id).then((used) => setRemaining(AUTH_LIMIT - used))
|
||||
} else {
|
||||
setRemaining(getRemainingChecks())
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
if (!user) setRemaining(getRemainingChecks())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">AI Chấm Writing</h1>
|
||||
<p className="text-slate-500 text-sm">Nhận phản hồi tức thì về ngữ pháp, từ vựng và cấu trúc bài viết.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-5">
|
||||
{/* Left: Input */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-semibold text-slate-700">Bài writing của bạn</span>
|
||||
<span className={cn('text-xs tabular-nums', charCount > MAX_CHARS ? 'text-red-500 font-bold' : 'text-slate-400')}>
|
||||
{charCount}/{MAX_CHARS}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value.slice(0, MAX_CHARS))}
|
||||
rows={12}
|
||||
placeholder="Nhập bài writing của bạn vào đây... (TOEIC email, IELTS task, hoặc đoạn văn tự do)"
|
||||
className="w-full resize-none rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-blue-400 focus:bg-white transition-colors"
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 14 }}>info</span>
|
||||
<span className={cn('text-xs font-medium', remaining <= 1 ? 'text-red-500' : 'text-slate-400')}>
|
||||
Còn {remaining}/{dailyLimit} lượt hôm nay
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-bold transition-all',
|
||||
canSubmit
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/20'
|
||||
: 'bg-slate-100 text-slate-400 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
|
||||
Đang chấm...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>auto_fix_high</span>
|
||||
Chấm bài ngay
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{remaining <= 0 && (
|
||||
<div className="mt-3 bg-amber-50 border border-amber-100 rounded-xl p-4 flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-amber-600 flex-shrink-0" style={{ fontSize: 20 }}>schedule</span>
|
||||
<p className="text-sm text-amber-700">
|
||||
Bạn đã dùng hết {dailyLimit} lượt hôm nay. Vui lòng quay lại vào ngày mai.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="mt-3 bg-red-50 border border-red-100 rounded-xl p-4 flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-red-500 flex-shrink-0" style={{ fontSize: 20 }}>error</span>
|
||||
<p className="text-sm text-red-600">
|
||||
{(error as Error)?.message ?? 'Đã có lỗi xảy ra. Vui lòng thử lại.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Feedback */}
|
||||
<div className="lg:w-80 flex-shrink-0">
|
||||
{!feedback && !isPending && (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col items-center justify-center text-center h-full min-h-48">
|
||||
<span className="material-symbols-outlined text-slate-300 mb-3" style={{ fontSize: 48 }}>auto_fix_high</span>
|
||||
<p className="text-sm text-slate-400">Nhập bài và nhấn "Chấm bài ngay" để nhận phản hồi từ AI</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPending && (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col items-center justify-center text-center h-full min-h-48">
|
||||
<div className="w-10 h-10 border-2 border-blue-100 border-t-blue-600 rounded-full animate-spin mb-4" />
|
||||
<p className="text-sm text-slate-500 font-medium">AI đang phân tích bài viết...</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Thường mất 3–5 giây</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedback && !isPending && (
|
||||
<div className="space-y-3">
|
||||
{/* Band score */}
|
||||
<div className="bg-blue-600 rounded-2xl p-5 text-center">
|
||||
<div className="text-xs text-blue-200 font-medium mb-1 uppercase tracking-wider">Band Score ước tính</div>
|
||||
<div className="text-5xl font-extrabold text-white mb-1">{feedback.score}</div>
|
||||
<div className="text-xs text-blue-200">Dựa trên tiêu chí IELTS/TOEIC Writing</div>
|
||||
</div>
|
||||
|
||||
{/* Grammar */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<span className="text-sm font-bold text-slate-800">Ngữ pháp</span>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{feedback.grammar.map((item, i) => (
|
||||
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-red-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>error</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Vocabulary */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
<span className="text-sm font-bold text-slate-800">Từ vựng</span>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{feedback.vocabulary.map((item, i) => (
|
||||
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-amber-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>lightbulb</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Structure */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="text-sm font-bold text-slate-800">Cấu trúc</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">{feedback.structure}</p>
|
||||
</div>
|
||||
|
||||
{/* Improved version */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||
<button
|
||||
onClick={() => setImprovedExpanded((v) => !v)}
|
||||
className="w-full flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-sm font-bold text-slate-800">Bài viết cải thiện</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 18 }}>
|
||||
{improvedExpanded ? 'expand_less' : 'expand_more'}
|
||||
</span>
|
||||
</button>
|
||||
{improvedExpanded && (
|
||||
<p className="mt-3 text-xs text-slate-600 leading-relaxed border-t border-slate-100 pt-3">
|
||||
{feedback.improvedVersion}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="bg-green-50 rounded-2xl border border-green-100 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 16 }}>summarize</span>
|
||||
<span className="text-sm font-bold text-green-700">Tổng nhận xét</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">{feedback.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { useLeaderboard } from '@/hooks/use-gamification'
|
||||
|
||||
function RankBadge({ rank }: { rank: number }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'w-8 h-8 flex items-center justify-center font-bold rounded-full text-xs',
|
||||
rank === 1 ? 'bg-amber-200 text-amber-800' :
|
||||
rank === 2 ? 'bg-slate-200 text-slate-700' :
|
||||
rank === 3 ? 'bg-orange-200 text-orange-700' :
|
||||
'bg-slate-100 text-slate-600',
|
||||
)}>
|
||||
{rank}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name.split(' ').map((w) => w[0]).slice(-2).join('').toUpperCase()
|
||||
}
|
||||
|
||||
export function LeaderboardCard() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const { data: rows, isLoading } = useLeaderboard()
|
||||
|
||||
return (
|
||||
<div className="lg:col-span-8 bg-white p-6 rounded-xl shadow-sm">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3 className="text-base font-bold text-slate-800">Bảng xếp hạng tuần</h3>
|
||||
<span className="px-3 py-1 bg-blue-600 text-white rounded-full text-xs font-bold">Top tuần này</span>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-12 bg-slate-100 rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (!rows || rows.length === 0) && (
|
||||
<div className="text-center py-8 text-slate-400 text-sm">
|
||||
<span className="material-symbols-outlined block mb-2 text-slate-300" style={{ fontSize: 40 }}>leaderboard</span>
|
||||
Chưa có dữ liệu tuần này. Hãy hoàn thành bài học để xuất hiện trên bảng!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && rows && rows.length > 0 && (
|
||||
<table className="w-full text-left border-separate border-spacing-y-1.5">
|
||||
<thead>
|
||||
<tr className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
||||
<th className="pb-2 pl-4 w-16">Hạng</th>
|
||||
<th className="pb-2">Người học</th>
|
||||
<th className="pb-2 text-right pr-4">XP tuần</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const isMe = row.userId === user?.id
|
||||
return (
|
||||
<tr
|
||||
key={row.userId}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
isMe
|
||||
? 'bg-blue-50 outline outline-2 outline-blue-200 rounded-xl'
|
||||
: 'bg-slate-50/60 hover:bg-slate-100',
|
||||
)}
|
||||
>
|
||||
<td className="py-2.5 pl-4 rounded-l-xl">
|
||||
<RankBadge rank={row.rank} />
|
||||
</td>
|
||||
<td className="py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0',
|
||||
isMe
|
||||
? 'bg-blue-600 text-white ring-2 ring-blue-300 ring-offset-1'
|
||||
: 'bg-slate-200 text-slate-600',
|
||||
)}>
|
||||
{initials(row.displayName)}
|
||||
</div>
|
||||
<span className={cn('text-sm font-bold', isMe && 'text-blue-600')}>
|
||||
{isMe ? `${row.displayName} (Bạn)` : row.displayName}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5 pr-4 text-right rounded-r-xl">
|
||||
<span className={cn('text-sm font-bold', isMe ? 'text-blue-600' : 'text-slate-600')}>
|
||||
{row.xpEarned.toLocaleString('vi-VN')} XP
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { UserLevel } from '@/types'
|
||||
import { calcNumericLevel } from '../Dashboard'
|
||||
|
||||
const LEVEL_NAMES: Record<UserLevel, string> = {
|
||||
beginner: 'Beginner',
|
||||
bronze: 'Đồng',
|
||||
silver: 'Bạc',
|
||||
gold: 'Vàng',
|
||||
master: 'Master',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
xu: number
|
||||
streak: number
|
||||
xp: number
|
||||
level: UserLevel
|
||||
}
|
||||
|
||||
export function StatsRow({ xu, streak, xp, level }: Props) {
|
||||
const numericLevel = calcNumericLevel(xp)
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-6">
|
||||
{/* Xu Balance */}
|
||||
<div className="relative overflow-hidden bg-white p-6 rounded-xl shadow-sm group">
|
||||
<div className="absolute -right-4 -top-4 w-24 h-24 bg-amber-100 rounded-full opacity-40 blur-2xl group-hover:opacity-60 transition-opacity" />
|
||||
<span className="text-xs uppercase tracking-widest text-slate-400 font-bold">Số dư Xu</span>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-4xl font-extrabold text-slate-800">{xu.toLocaleString('vi-VN')}</span>
|
||||
<span className="material-symbols-outlined text-3xl text-amber-400" style={{ fontVariationSettings: "'FILL' 1" }}>
|
||||
monetization_on
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1.5 font-medium">Dùng để mở tính năng premium</p>
|
||||
</div>
|
||||
|
||||
{/* Streak */}
|
||||
<div className="relative overflow-hidden bg-blue-600 p-6 rounded-xl shadow-sm">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500 to-blue-700 opacity-90" />
|
||||
<div className="relative z-10 text-white">
|
||||
<span className="text-xs uppercase tracking-widest opacity-75 font-bold">Chuỗi học tập</span>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-4xl font-extrabold">{streak} Ngày</span>
|
||||
<span className="material-symbols-outlined text-3xl text-amber-300" style={{ fontVariationSettings: "'FILL' 1" }}>
|
||||
local_fire_department
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs opacity-80 mt-1.5 font-medium">
|
||||
{streak >= 7 ? 'Bạn thuộc top 5% người học!' : 'Giữ vững chuỗi học mỗi ngày nhé!'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Level */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-widest text-slate-400 font-bold">Cấp độ</span>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-4xl font-extrabold text-slate-800">Level {numericLevel}</span>
|
||||
</div>
|
||||
<span className="inline-block mt-2 px-3 py-1 bg-amber-50 text-amber-600 text-xs font-bold rounded-full border border-amber-200">
|
||||
Hạng {LEVEL_NAMES[level]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-14 h-14 bg-slate-100 flex items-center justify-center rounded-2xl rotate-12 flex-shrink-0">
|
||||
<span className="material-symbols-outlined text-blue-600 text-3xl">military_tech</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
streak: number
|
||||
lastActive: string | null
|
||||
weeklyCompleted: number
|
||||
}
|
||||
|
||||
const WEEKLY_GOAL = 5
|
||||
const DAY_LABELS = ['Th 2', 'Th 3', 'Th 4', 'Th 5', 'Th 6', 'Th 7', 'CN']
|
||||
|
||||
function getTodayIdx() {
|
||||
const d = new Date().getDay() // 0=Sun
|
||||
return d === 0 ? 6 : d - 1 // Mon=0 … Sun=6
|
||||
}
|
||||
|
||||
// Derive which days were active this week from streak + lastActive
|
||||
function getWeekActivity(streak: number, lastActive: string | null): boolean[] {
|
||||
const todayIdx = getTodayIdx()
|
||||
const activity = Array(7).fill(false)
|
||||
if (!lastActive) return activity
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const isActiveToday = lastActive === today
|
||||
// Mark past days as done based on streak length (up to but not including today)
|
||||
const doneDays = isActiveToday ? Math.min(todayIdx, streak - 1) : Math.min(todayIdx, streak)
|
||||
for (let i = todayIdx - doneDays; i < todayIdx; i++) {
|
||||
if (i >= 0) activity[i] = true
|
||||
}
|
||||
return activity
|
||||
}
|
||||
|
||||
export function WeeklySection({ streak, lastActive, weeklyCompleted }: Props) {
|
||||
const todayIdx = getTodayIdx()
|
||||
const weekActivity = getWeekActivity(streak, lastActive)
|
||||
const progressPct = Math.round((weeklyCompleted / WEEKLY_GOAL) * 100)
|
||||
|
||||
return (
|
||||
<div className="lg:col-span-7 space-y-5">
|
||||
{/* Weekly goal */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm">
|
||||
<div className="flex justify-between items-end mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-slate-800">Mục tiêu tuần</h3>
|
||||
<p className="text-xs text-slate-400">Hoàn thành {WEEKLY_GOAL} bài học mỗi tuần</p>
|
||||
</div>
|
||||
<span className="text-2xl font-black text-green-600">
|
||||
{weeklyCompleted}/{WEEKLY_GOAL}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-3 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly heatmap */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm">
|
||||
<h3 className="text-base font-bold text-slate-800 mb-5">Lịch sử rèn luyện</h3>
|
||||
<div className="flex justify-between items-center">
|
||||
{DAY_LABELS.map((label, i) => {
|
||||
const isToday = i === todayIdx
|
||||
const done = weekActivity[i]
|
||||
const future = i > todayIdx
|
||||
|
||||
return (
|
||||
<div key={label} className={cn('flex flex-col items-center gap-2.5', future && 'opacity-30')}>
|
||||
<span className={cn('text-[10px] font-bold uppercase', isToday ? 'text-blue-600' : 'text-slate-400')}>
|
||||
{isToday ? 'H.Nay' : label}
|
||||
</span>
|
||||
{isToday ? (
|
||||
<div className="w-10 h-10 rounded-xl border-2 border-blue-600 border-dashed flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 18 }}>play_arrow</span>
|
||||
</div>
|
||||
) : done ? (
|
||||
<div className="w-10 h-10 rounded-xl bg-green-200 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-green-700" style={{ fontSize: 18 }}>check</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-xl bg-slate-100" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { calcXpNextLevel, calcNumericLevel } from '../Dashboard'
|
||||
|
||||
interface Props { xp: number }
|
||||
|
||||
function ProgressRing({ percent, xp, xpNext }: { percent: number; xp: number; xpNext: number }) {
|
||||
const r = 72
|
||||
const circ = 2 * Math.PI * r
|
||||
const offset = circ - (percent / 100) * circ
|
||||
|
||||
return (
|
||||
<div className="relative w-44 h-44">
|
||||
<svg className="w-full h-full -rotate-90" viewBox="0 0 160 160">
|
||||
<circle cx="80" cy="80" r={r} fill="transparent" stroke="#e8eaed" strokeWidth="12" />
|
||||
<circle
|
||||
cx="80" cy="80" r={r}
|
||||
fill="transparent"
|
||||
stroke="#2563eb"
|
||||
strokeWidth="12"
|
||||
strokeDasharray={circ}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
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">{percent}%</span>
|
||||
<span className="text-[10px] text-slate-400 font-bold mt-0.5">
|
||||
{xp.toLocaleString()} / {xpNext.toLocaleString()} XP
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function XpProgressCard({ xp }: Props) {
|
||||
const xpNext = calcXpNextLevel(xp)
|
||||
const levelXpStart = Math.floor(xp / 100) * 100
|
||||
const percent = Math.round(((xp - levelXpStart) / (xpNext - levelXpStart)) * 100)
|
||||
const nextLevel = calcNumericLevel(xp) + 1
|
||||
|
||||
return (
|
||||
<div className="lg:col-span-5 bg-white p-6 rounded-xl shadow-sm flex flex-col items-center justify-center text-center">
|
||||
<h3 className="text-base font-bold mb-5 self-start text-slate-800">Tiến độ Cấp độ</h3>
|
||||
|
||||
<ProgressRing percent={percent} xp={xp} xpNext={xpNext} />
|
||||
|
||||
<p className="text-sm text-slate-400 font-medium mt-4">
|
||||
Chỉ còn {(xpNext - xp).toLocaleString()} XP nữa để đạt Level {nextLevel}!
|
||||
</p>
|
||||
|
||||
<button className="mt-5 w-full py-2.5 bg-slate-100 hover:bg-slate-200 transition-colors rounded-xl font-bold text-sm text-blue-600">
|
||||
Xem nhiệm vụ XP
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
const EARN_ITEMS = [
|
||||
{ label: 'Mục tiêu ngày', reward: '+10 xu' },
|
||||
{ label: 'Mốc chuỗi (Streak)', reward: '+20 xu' },
|
||||
{ label: 'Xem quảng cáo', reward: '+5 xu' },
|
||||
]
|
||||
|
||||
const SPEND_ITEMS = [
|
||||
{ label: 'Streak Freeze', cost: '20 xu' },
|
||||
{ label: 'AI Writing Feedback', cost: '30 xu' },
|
||||
]
|
||||
|
||||
export function XuEconomyCard() {
|
||||
return (
|
||||
<div className="lg:col-span-4 bg-white p-6 rounded-xl shadow-sm">
|
||||
<h3 className="text-base font-bold text-slate-800 mb-5">Cửa hàng Xu</h3>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Earn */}
|
||||
<div>
|
||||
<span className="text-xs text-green-600 font-bold uppercase tracking-wider block mb-2.5">Kiếm Xu</span>
|
||||
<div className="space-y-2">
|
||||
{EARN_ITEMS.map((item) => (
|
||||
<div key={item.label} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
||||
<span className="text-sm font-medium text-slate-700">{item.label}</span>
|
||||
<span className="text-sm font-bold text-amber-600">{item.reward}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spend */}
|
||||
<div>
|
||||
<span className="text-xs text-red-500 font-bold uppercase tracking-wider block mb-2.5">Tiêu Xu</span>
|
||||
<div className="space-y-2">
|
||||
{SPEND_ITEMS.map((item) => (
|
||||
<div key={item.label} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg opacity-80">
|
||||
<span className="text-sm font-medium text-slate-700">{item.label}</span>
|
||||
<span className="text-sm font-bold text-slate-400">{item.cost}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// Phase 3 bridge: gamification state from localStorage until DB is live
|
||||
|
||||
export interface GamificationState {
|
||||
xu: number
|
||||
streak: number
|
||||
xp: number
|
||||
xpNextLevel: number
|
||||
level: number
|
||||
levelName: string
|
||||
weeklyCompleted: number
|
||||
weeklyGoal: number
|
||||
weekActivity: boolean[] // Mon–Sun, true = completed
|
||||
}
|
||||
|
||||
const KEYS = {
|
||||
xu: 'xu_balance',
|
||||
streak: 'gamification_streak',
|
||||
xp: 'gamification_xp',
|
||||
level: 'gamification_level',
|
||||
weeklyCompleted: 'gamification_weekly_completed',
|
||||
}
|
||||
|
||||
function getNum(key: string, fallback: number) {
|
||||
const v = localStorage.getItem(key)
|
||||
return v ? parseInt(v, 10) : fallback
|
||||
}
|
||||
|
||||
function getLevelName(level: number): string {
|
||||
if (level >= 40) return 'Master'
|
||||
if (level >= 20) return 'Gold'
|
||||
if (level >= 10) return 'Silver'
|
||||
if (level >= 5) return 'Bronze'
|
||||
return 'Beginner'
|
||||
}
|
||||
|
||||
function getWeekActivity(): boolean[] {
|
||||
// Days Mon–Sun — mark days up to (but not including) today as done if streak is active
|
||||
const today = new Date().getDay() // 0=Sun, 1=Mon ... 6=Sat
|
||||
const streak = getNum(KEYS.streak, 14)
|
||||
// Convert to Mon=0 index
|
||||
const todayIdx = today === 0 ? 6 : today - 1
|
||||
const activity = Array(7).fill(false)
|
||||
for (let i = 0; i < Math.min(todayIdx, streak, 7); i++) {
|
||||
activity[i] = true
|
||||
}
|
||||
return activity
|
||||
}
|
||||
|
||||
export function loadGamification(): GamificationState {
|
||||
const level = getNum(KEYS.level, 14)
|
||||
const xp = getNum(KEYS.xp, 1200)
|
||||
|
||||
return {
|
||||
xu: getNum(KEYS.xu, 50),
|
||||
streak: getNum(KEYS.streak, 14),
|
||||
xp,
|
||||
xpNextLevel: 1600, // hardcoded for Phase 3 demo
|
||||
level,
|
||||
levelName: getLevelName(level),
|
||||
weeklyCompleted: getNum(KEYS.weeklyCompleted, 3),
|
||||
weeklyGoal: 5,
|
||||
weekActivity: getWeekActivity(),
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createRootRoute, Outlet } from '@tanstack/react-router'
|
||||
import { useEffect } from 'react'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
import { MobileNav } from '@/components/MobileNav'
|
||||
import { AuthModal } from '@/components/auth/AuthModal'
|
||||
import { Sidebar } from '@/components/layout/Sidebar'
|
||||
import { AppHeader } from '@/components/layout/AppHeader'
|
||||
import { MobileNav } from '@/components/layout/MobileNav'
|
||||
import { AuthModal } from '@/features/auth/components/AuthModal'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
|
||||
export const Route = createRootRoute({
|
||||
|
||||
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,
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { LoginPage } from '@/pages/Login'
|
||||
import { LoginPage } from '@/features/auth/components/LoginPage'
|
||||
|
||||
export const Route = createFileRoute('/auth/login')({
|
||||
component: LoginPage,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { RegisterPage } from '@/pages/Register'
|
||||
import { RegisterPage } from '@/features/auth/components/RegisterPage'
|
||||
|
||||
export const Route = createFileRoute('/auth/register')({
|
||||
component: RegisterPage,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Dashboard } from '@/pages/Dashboard'
|
||||
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
component: Dashboard,
|
||||
})
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/flash-card")({
|
||||
component: () => <Outlet />,
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Home } from "@/pages/Home"
|
||||
import { Home } from "@/features/home/components/Home"
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: Home,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Settings } from '@/pages/Settings'
|
||||
import { Settings } from '@/features/settings/components/Settings'
|
||||
|
||||
export const Route = createFileRoute('/settings')({
|
||||
component: Settings,
|
||||
|
||||
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)} />
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { ToeicPractice } from "@/pages/ToeicPractice"
|
||||
import { ToeicTestList } from "@/features/toeic/components/ToeicTestList"
|
||||
|
||||
export const Route = createFileRoute("/toeic/")({
|
||||
component: ToeicPractice,
|
||||
component: ToeicTestList,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { TestResult } from "@/pages/TestResult"
|
||||
import { TestResult } from "@/features/toeic/components/TestResult"
|
||||
|
||||
export const Route = createFileRoute("/toeic/result")({
|
||||
component: TestResult,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { TestSession } from "@/pages/TestSession"
|
||||
import { TestSession } from "@/features/toeic/components/TestSession"
|
||||
|
||||
export const Route = createFileRoute("/toeic/session")({
|
||||
component: TestSession,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Vocabulary } from "@/pages/Vocabulary"
|
||||
|
||||
export const Route = createFileRoute("/vocab")({
|
||||
component: Vocabulary,
|
||||
})
|
||||
@@ -1,6 +1,16 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { WritingChecker } from "@/pages/WritingChecker"
|
||||
import { WritingChecker } from "@/features/writing/components/WritingChecker"
|
||||
import { WritingHistory } from "@/features/writing/components/WritingHistory"
|
||||
|
||||
function WritingPage() {
|
||||
return (
|
||||
<>
|
||||
<WritingChecker />
|
||||
<WritingHistory />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/writing")({
|
||||
component: WritingChecker,
|
||||
component: WritingPage,
|
||||
})
|
||||
|
||||
@@ -1,52 +1,66 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { Question } from '@/types'
|
||||
import type { SessionPart } from '@/types'
|
||||
|
||||
interface StartExamConfig {
|
||||
testId: number | null
|
||||
testName: string
|
||||
parts: SessionPart[]
|
||||
totalSeconds: number // 0 = no limit
|
||||
}
|
||||
|
||||
interface TestStore {
|
||||
partId: number
|
||||
partName: string
|
||||
questions: Question[]
|
||||
answers: (number | null)[]
|
||||
testId: number | null
|
||||
testName: string
|
||||
parts: SessionPart[]
|
||||
currentPartIndex: number
|
||||
answers: Record<number, number | null> // questionId → answerIndex (0-3), null=unanswered
|
||||
isSubmitted: boolean
|
||||
timeUsed: number // seconds elapsed when submitted
|
||||
timeUsed: number // seconds elapsed when submitted
|
||||
totalSeconds: number // time limit (0 = no limit)
|
||||
|
||||
startExam: (partId: number, partName: string, questions: Question[]) => void
|
||||
setAnswer: (questionIndex: number, answerIndex: number) => void
|
||||
startExam: (config: StartExamConfig) => void
|
||||
setAnswer: (questionId: number, answerIndex: number) => void
|
||||
setCurrentPart: (partIndex: number) => void
|
||||
submitExam: (timeUsed: number) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const INITIAL_STATE = {
|
||||
testId: null,
|
||||
testName: '',
|
||||
parts: [],
|
||||
currentPartIndex: 0,
|
||||
answers: {},
|
||||
isSubmitted: false,
|
||||
timeUsed: 0,
|
||||
totalSeconds: 0,
|
||||
}
|
||||
|
||||
export const useTestStore = create<TestStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
partId: 2,
|
||||
partName: '',
|
||||
questions: [],
|
||||
answers: [],
|
||||
isSubmitted: false,
|
||||
timeUsed: 0,
|
||||
...INITIAL_STATE,
|
||||
|
||||
startExam: (partId, partName, questions) =>
|
||||
set({
|
||||
partId,
|
||||
partName,
|
||||
questions,
|
||||
answers: new Array(questions.length).fill(null),
|
||||
isSubmitted: false,
|
||||
timeUsed: 0,
|
||||
}),
|
||||
startExam: ({ testId, testName, parts, totalSeconds }) => {
|
||||
// Pre-fill all question IDs with null (unanswered)
|
||||
const answers: Record<number, number | null> = {}
|
||||
for (const part of parts) {
|
||||
for (const q of part.questions) answers[q.id] = null
|
||||
}
|
||||
set({ testId, testName, parts, currentPartIndex: 0, answers, isSubmitted: false, timeUsed: 0, totalSeconds })
|
||||
},
|
||||
|
||||
setAnswer: (questionIndex, answerIndex) =>
|
||||
set((state) => {
|
||||
const answers = [...state.answers]
|
||||
answers[questionIndex] = answerIndex
|
||||
return { answers }
|
||||
}),
|
||||
setAnswer: (questionId, answerIndex) =>
|
||||
set((state) => ({
|
||||
answers: { ...state.answers, [questionId]: answerIndex },
|
||||
})),
|
||||
|
||||
setCurrentPart: (partIndex) => set({ currentPartIndex: partIndex }),
|
||||
|
||||
submitExam: (timeUsed) => set({ isSubmitted: true, timeUsed }),
|
||||
|
||||
reset: () =>
|
||||
set({ partId: 2, partName: '', questions: [], answers: [], isSubmitted: false, timeUsed: 0 }),
|
||||
reset: () => set(INITIAL_STATE),
|
||||
}),
|
||||
{ name: 'test-store' },
|
||||
),
|
||||
|
||||
132
src/temp/local-data.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* TEMPORARY LOCAL DATA
|
||||
* ====================
|
||||
* This file contains all placeholder, mock, and locally-hardcoded data.
|
||||
* Each export is tagged with its status:
|
||||
*
|
||||
* [ACTIVE TEMP] — still used in production code; needs real DB data to replace it
|
||||
* [UNUSED] — not imported anywhere; kept as reference until Supabase seed is confirmed
|
||||
* [SUPERSEDED] — replaced by real DB service, kept for reference only
|
||||
*
|
||||
* When real database data is available, remove the relevant export and update the consumer.
|
||||
*/
|
||||
|
||||
import type { VocabWord, WritingFeedback, ToeicPart } from '@/types'
|
||||
|
||||
// ─── [ACTIVE TEMP] ─────────────────────────────────────────────────────────────
|
||||
// Used by: src/pages/ToeicPractice.tsx
|
||||
// Replace with: DB query on `questions` table (group by part, count) + user progress %
|
||||
// Note: `progressPercent` is hardcoded — user's real per-part progress should come from user_progress
|
||||
export const TOEIC_PARTS: ToeicPart[] = [
|
||||
{ id: 1, name: 'Part 1', nameVi: 'Mô tả hình ảnh', questionCount: 45, icon: 'image', progressPercent: 60 },
|
||||
{ id: 2, name: 'Part 2', nameVi: 'Hỏi-đáp', questionCount: 30, icon: 'question_answer', progressPercent: 40 },
|
||||
{ id: 3, name: 'Part 3', nameVi: 'Đoạn hội thoại', questionCount: 39, icon: 'forum', progressPercent: 25 },
|
||||
{ id: 4, name: 'Part 4', nameVi: 'Bài nói', questionCount: 30, icon: 'record_voice_over', progressPercent: 10 },
|
||||
{ id: 5, name: 'Part 5', nameVi: 'Điền từ', questionCount: 40, icon: 'history_edu', progressPercent: 80 },
|
||||
{ id: 6, name: 'Part 6', nameVi: 'Điền đoạn', questionCount: 16, icon: 'article', progressPercent: 50 },
|
||||
{ id: 7, name: 'Part 7', nameVi: 'Đọc hiểu', questionCount: 54, icon: 'chrome_reader_mode', progressPercent: 30 },
|
||||
]
|
||||
|
||||
// ─── [UNUSED] ──────────────────────────────────────────────────────────────────
|
||||
// Real questions come from Supabase via fetchQuestionsForTest() in src/hooks/use-questions.ts
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const MOCK_QUESTIONS: any[] = [
|
||||
{ id: 'q1', part: 2, text: 'What does the man suggest the woman do about the budget report?', options: ['A. Submit it immediately', 'B. Review it again carefully', 'C. Postpone the deadline', 'D. Ask a colleague for help'], correctAnswer: 1, explanation: 'Người đàn ông nói "You should review it carefully before submitting" — gợi ý xem xét lại báo cáo trước khi nộp.' },
|
||||
{ id: 'q2', part: 2, text: 'Where most likely are the speakers?', options: ['A. In a restaurant', 'B. At a conference', 'C. In an office', 'D. At an airport'], correctAnswer: 2, explanation: 'Các từ như "meeting room", "printer", "desk" cho biết cuộc trò chuyện diễn ra trong văn phòng.' },
|
||||
{ id: 'q3', part: 2, text: 'Why is the man calling?', options: ['A. To confirm a reservation', 'B. To cancel an appointment', 'C. To reschedule a meeting', 'D. To order supplies'], correctAnswer: 0, explanation: 'Từ "confirm" và "booking number" trong hội thoại chỉ rõ mục đích của cuộc gọi là xác nhận đặt chỗ.' },
|
||||
{ id: 'q4', part: 2, text: 'What will the woman do next?', options: ['A. Call the manager', 'B. Send an email', 'C. Check the inventory', 'D. Update the schedule'], correctAnswer: 3, explanation: 'Người phụ nữ nói "I\'ll update the schedule right away" — cho biết hành động tiếp theo là cập nhật lịch trình.' },
|
||||
{ id: 'q5', part: 2, text: 'What problem does the man mention?', options: ['A. A delayed shipment', 'B. A broken device', 'C. A missing document', 'D. A scheduling conflict'], correctAnswer: 0, explanation: '"The delivery has been delayed by two days" — vấn đề được đề cập là lô hàng bị trễ.' },
|
||||
{ id: 'q6', part: 2, text: 'How does the woman respond to the proposal?', options: ['A. She accepts it', 'B. She rejects it', 'C. She needs more time', 'D. She suggests modifications'], correctAnswer: 3, explanation: '"That sounds good, but maybe we could adjust the timeline a bit" — đề xuất điều chỉnh, không chấp nhận hoàn toàn.' },
|
||||
{ id: 'q7', part: 2, text: 'What is the purpose of the announcement?', options: ['A. To introduce new products', 'B. To notify schedule changes', 'C. To welcome new employees', 'D. To announce a promotion'], correctAnswer: 1, explanation: 'Thông báo nói về việc thay đổi giờ làm việc từ tuần tới — mục đích là thông báo thay đổi lịch.' },
|
||||
{ id: 'q8', part: 2, text: 'What does the woman ask the man to do?', options: ['A. Prepare a presentation', 'B. Contact the client', 'C. Review the contract', 'D. Attend a training session'], correctAnswer: 2, explanation: '"Could you go over the contract before we sign?" — người phụ nữ yêu cầu xem lại hợp đồng.' },
|
||||
{ id: 'q9', part: 2, text: 'When will the project be completed?', options: ['A. By the end of this week', 'B. Next Monday', 'C. In two weeks', 'D. Next month'], correctAnswer: 0, explanation: '"We should be finished by Friday" — dự án sẽ hoàn thành vào cuối tuần này.' },
|
||||
{ id: 'q10', part: 2, text: 'What is being discussed at the meeting?', options: ['A. Budget allocations', 'B. Marketing strategies', 'C. Product launches', 'D. Staff promotions'], correctAnswer: 1, explanation: 'Cuộc họp tập trung vào "the new advertising campaign and social media approach" — chiến lược marketing.' },
|
||||
]
|
||||
|
||||
// ─── [UNUSED] ──────────────────────────────────────────────────────────────────
|
||||
// Real vocab comes from Supabase via useVocab() in src/hooks/use-vocab.ts
|
||||
export const VOCAB_DATA: Record<string, VocabWord[]> = {
|
||||
'Tất cả': [
|
||||
{ id: 'v1', word: 'negotiate', phonetic: '/nɪˈɡoʊʃieɪt/', meaningVi: 'đàm phán', topic: 'Business', example: 'We need to negotiate the contract terms before signing.' },
|
||||
{ id: 'v2', word: 'collaborate', phonetic: '/kəˈlæbəreɪt/', meaningVi: 'hợp tác', topic: 'Business', example: 'Teams need to collaborate effectively to meet the deadline.' },
|
||||
{ id: 'v3', word: 'agenda', phonetic: '/əˈdʒendə/', meaningVi: 'chương trình nghị sự', topic: 'Office', example: 'The agenda has been sent to all meeting participants.' },
|
||||
{ id: 'v4', word: 'itinerary', phonetic: '/aɪˈtɪnəreri/', meaningVi: 'lịch trình chuyến đi', topic: 'Travel', example: 'Here is your travel itinerary for the business conference.' },
|
||||
{ id: 'v5', word: 'reimburse', phonetic: '/ˌriːɪmˈbɜːrs/', meaningVi: 'hoàn tiền', topic: 'Finance', example: 'The company will reimburse your travel expenses.' },
|
||||
{ id: 'v6', word: 'recruit', phonetic: '/rɪˈkruːt/', meaningVi: 'tuyển dụng', topic: 'HR', example: 'We are actively recruiting experienced engineers.' },
|
||||
{ id: 'v7', word: 'campaign', phonetic: '/kæmˈpeɪn/', meaningVi: 'chiến dịch', topic: 'Marketing', example: 'The marketing campaign was very successful this quarter.' },
|
||||
{ id: 'v8', word: 'implement', phonetic: '/ˈɪmplɪment/', meaningVi: 'triển khai, thực hiện', topic: 'Business', example: 'We plan to implement the new strategy next quarter.' },
|
||||
],
|
||||
'Business': [
|
||||
{ id: 'b1', word: 'negotiate', phonetic: '/nɪˈɡoʊʃieɪt/', meaningVi: 'đàm phán', topic: 'Business', example: 'We need to negotiate the contract terms.' },
|
||||
{ id: 'b2', word: 'collaborate', phonetic: '/kəˈlæbəreɪt/', meaningVi: 'hợp tác', topic: 'Business', example: 'Teams collaborate to achieve shared goals.' },
|
||||
{ id: 'b3', word: 'delegate', phonetic: '/ˈdelɪɡeɪt/', meaningVi: 'uỷ quyền, phân công', topic: 'Business', example: 'A good manager knows how to delegate tasks.' },
|
||||
{ id: 'b4', word: 'implement', phonetic: '/ˈɪmplɪment/', meaningVi: 'triển khai, thực hiện', topic: 'Business', example: 'We will implement the new policy next month.' },
|
||||
{ id: 'b5', word: 'merger', phonetic: '/ˈmɜːrdʒər/', meaningVi: 'sáp nhập công ty', topic: 'Business', example: 'The merger will create a stronger combined company.' },
|
||||
{ id: 'b6', word: 'acquisition', phonetic: '/ˌækwɪˈzɪʃən/', meaningVi: 'mua lại, thâu tóm', topic: 'Business', example: 'The acquisition was completed ahead of schedule.' },
|
||||
],
|
||||
'Office': [
|
||||
{ id: 'o1', word: 'agenda', phonetic: '/əˈdʒendə/', meaningVi: 'chương trình nghị sự', topic: 'Office', example: 'Please review the agenda before the meeting.' },
|
||||
{ id: 'o2', word: 'minutes', phonetic: '/ˈmɪnɪts/', meaningVi: 'biên bản họp', topic: 'Office', example: 'Could you take the meeting minutes today?' },
|
||||
{ id: 'o3', word: 'submit', phonetic: '/səbˈmɪt/', meaningVi: 'nộp, gửi đi', topic: 'Office', example: 'Please submit your report by Friday afternoon.' },
|
||||
{ id: 'o4', word: 'deadline', phonetic: '/ˈdedlaɪn/', meaningVi: 'hạn chót', topic: 'Office', example: 'The deadline for this project is end of month.' },
|
||||
{ id: 'o5', word: 'cubicle', phonetic: '/ˈkjuːbɪkəl/', meaningVi: 'góc làm việc riêng', topic: 'Office', example: 'Each employee has their own cubicle in the open office.' },
|
||||
],
|
||||
'Travel': [
|
||||
{ id: 't1', word: 'itinerary', phonetic: '/aɪˈtɪnəreri/', meaningVi: 'lịch trình chuyến đi', topic: 'Travel', example: 'Here is your detailed travel itinerary.' },
|
||||
{ id: 't2', word: 'boarding pass', phonetic: '/ˈbɔːrdɪŋ pæs/', meaningVi: 'thẻ lên máy bay', topic: 'Travel', example: 'Please have your boarding pass ready at the gate.' },
|
||||
{ id: 't3', word: 'layover', phonetic: '/ˈleɪoʊvər/', meaningVi: 'thời gian quá cảnh', topic: 'Travel', example: 'There is a two-hour layover in Singapore.' },
|
||||
{ id: 't4', word: 'customs', phonetic: '/ˈkʌstəmz/', meaningVi: 'hải quan', topic: 'Travel', example: 'All passengers must go through customs on arrival.' },
|
||||
{ id: 't5', word: 'baggage claim', phonetic: '/ˈbæɡɪdʒ kleɪm/', meaningVi: 'băng chuyền hành lý', topic: 'Travel', example: 'Meet us at the baggage claim after landing.' },
|
||||
],
|
||||
'Finance': [
|
||||
{ id: 'f1', word: 'reimburse', phonetic: '/ˌriːɪmˈbɜːrs/', meaningVi: 'hoàn tiền', topic: 'Finance', example: 'The company will reimburse all travel expenses.' },
|
||||
{ id: 'f2', word: 'invoice', phonetic: '/ˈɪnvɔɪs/', meaningVi: 'hoá đơn', topic: 'Finance', example: 'Please send the invoice to our accounting department.' },
|
||||
{ id: 'f3', word: 'budget', phonetic: '/ˈbʌdʒɪt/', meaningVi: 'ngân sách', topic: 'Finance', example: 'We need to stay within the approved budget.' },
|
||||
{ id: 'f4', word: 'revenue', phonetic: '/ˈrevɪnjuː/', meaningVi: 'doanh thu', topic: 'Finance', example: 'Revenue increased by 15% last quarter.' },
|
||||
{ id: 'f5', word: 'fiscal year', phonetic: '/ˈfɪskəl jɪər/', meaningVi: 'năm tài chính', topic: 'Finance', example: 'Our fiscal year ends on December 31st.' },
|
||||
],
|
||||
'HR': [
|
||||
{ id: 'h1', word: 'recruit', phonetic: '/rɪˈkruːt/', meaningVi: 'tuyển dụng', topic: 'HR', example: 'We are recruiting experienced software engineers.' },
|
||||
{ id: 'h2', word: 'probation', phonetic: '/proʊˈbeɪʃən/', meaningVi: 'thử việc', topic: 'HR', example: 'New employees have a 3-month probation period.' },
|
||||
{ id: 'h3', word: 'appraisal', phonetic: '/əˈpreɪzəl/', meaningVi: 'đánh giá nhân viên', topic: 'HR', example: 'Annual performance appraisals are held in December.' },
|
||||
{ id: 'h4', word: 'resignation', phonetic: '/ˌrezɪɡˈneɪʃən/', meaningVi: 'đơn từ chức', topic: 'HR', example: 'She submitted her resignation letter this morning.' },
|
||||
{ id: 'h5', word: 'onboarding', phonetic: '/ˈɒnbɔːrdɪŋ/', meaningVi: 'quy trình tiếp nhận nhân viên mới', topic: 'HR', example: 'The onboarding process takes about two weeks.' },
|
||||
],
|
||||
'Marketing': [
|
||||
{ id: 'm1', word: 'campaign', phonetic: '/kæmˈpeɪn/', meaningVi: 'chiến dịch', topic: 'Marketing', example: 'The marketing campaign exceeded all expectations.' },
|
||||
{ id: 'm2', word: 'demographics', phonetic: '/ˌdeməˈɡræfɪks/', meaningVi: 'nhân khẩu học', topic: 'Marketing', example: 'We need to understand our target demographics.' },
|
||||
{ id: 'm3', word: 'endorse', phonetic: '/ɪnˈdɔːrs/', meaningVi: 'chứng thực, bảo trợ', topic: 'Marketing', example: 'The product is endorsed by professional athletes.' },
|
||||
{ id: 'm4', word: 'branding', phonetic: '/ˈbrændɪŋ/', meaningVi: 'xây dựng thương hiệu', topic: 'Marketing', example: 'Consistent branding builds long-term customer trust.' },
|
||||
{ id: 'm5', word: 'conversion rate', phonetic: '/kənˈvɜːrʒən reɪt/', meaningVi: 'tỷ lệ chuyển đổi', topic: 'Marketing', example: 'Our conversion rate improved after the redesign.' },
|
||||
],
|
||||
}
|
||||
|
||||
// ─── [UNUSED] ──────────────────────────────────────────────────────────────────
|
||||
// Real writing feedback is generated server-side via the GLM Edge Function
|
||||
// Used only as a visual reference during development
|
||||
export const MOCK_WRITING_FEEDBACK: WritingFeedback = {
|
||||
score: '6.5',
|
||||
grammar: [
|
||||
'"managers are concern" → nên dùng "concerned" (tính từ, không phải danh từ)',
|
||||
'Thiếu mạo từ "an" trước "efficient arrangement" ở câu cuối',
|
||||
'Câu "This change is expected to improve" — đúng nhưng hơi thụ động, có thể dùng active voice',
|
||||
],
|
||||
vocabulary: [
|
||||
'Tốt: "implement", "productivity", "collaboration", "arrangement"',
|
||||
'Gợi ý nâng cao: "enhance" thay "increase", "address" thay "help with"',
|
||||
'Nên thêm từ nối: "Nevertheless", "In addition", "As a result of this"',
|
||||
],
|
||||
structure: 'Bài viết có cấu trúc khá rõ ràng với mở đầu, thân bài và kết luận ngầm. Tuy nhiên cần phát triển thêm phần giải thích tác động và thêm ví dụ cụ thể để bài hoàn chỉnh hơn.',
|
||||
improvedVersion: 'The company has decided to implement a new remote work policy starting next month. All employees will be able to work from home for three days per week. This change is expected to enhance work-life balance and boost overall productivity. Nevertheless, some managers are concerned about communication challenges and team collaboration. To address these concerns, the HR department will organize training sessions to help teams adapt to this new arrangement effectively.',
|
||||
summary: 'Bài viết đạt mức Upper Intermediate (6.5) với ý tưởng rõ ràng. Cần sửa lỗi ngữ pháp cơ bản và bổ sung từ vựng phong phú hơn để đạt band 7.0+.',
|
||||
}
|
||||
|
||||
// ─── [SUPERSEDED] ─────────────────────────────────────────────────────────────
|
||||
// Was: src/pages/dashboard/gamification-store.ts
|
||||
// Replaced by: src/lib/gamification-service.ts + src/hooks/use-gamification.ts (Supabase)
|
||||
// Kept here for reference — DO NOT import in production code
|
||||
export interface _LegacyGamificationState {
|
||||
xu: number; streak: number; xp: number; xpNextLevel: number
|
||||
level: number; levelName: string; weeklyCompleted: number
|
||||
weeklyGoal: number; weekActivity: boolean[]
|
||||
}
|
||||
@@ -1,10 +1,40 @@
|
||||
export interface Question {
|
||||
id: string
|
||||
part: number
|
||||
text: string
|
||||
options: string[]
|
||||
correctAnswer: number // 0-3
|
||||
explanation: string
|
||||
id: number // SERIAL from question table
|
||||
partNumber: number // from part.part_number — needed for session grouping
|
||||
text: string | null // question_text (null for photo/audio-only questions)
|
||||
options: string[] // answer_choice.label_text ordered A→D
|
||||
correctAnswer: number // 0-3 derived from answer_choice.is_correct
|
||||
explanation: string | null
|
||||
groupId: number
|
||||
audioUrl?: string // from question_group
|
||||
imageUrl?: string // from question_group
|
||||
passageText?: string // from question_group (Part 6/7)
|
||||
}
|
||||
|
||||
// One part's worth of questions inside a test session
|
||||
export interface SessionPart {
|
||||
partNumber: number
|
||||
partName: string // e.g. "Mô tả hình ảnh"
|
||||
questions: Question[]
|
||||
}
|
||||
|
||||
// A test record from the test table
|
||||
export interface TestRecord {
|
||||
id: number
|
||||
title: string
|
||||
description: string | null
|
||||
totalQuestions: number
|
||||
durationMinutes: number
|
||||
categoryName: string | null
|
||||
}
|
||||
|
||||
// A part record from the part table
|
||||
export interface PartRecord {
|
||||
id: number
|
||||
testId: number
|
||||
partNumber: number
|
||||
title: string
|
||||
questionCount: number
|
||||
}
|
||||
|
||||
export interface VocabWord {
|
||||
@@ -44,6 +74,13 @@ export interface WritingFeedback {
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface WritingSubmission {
|
||||
id: string
|
||||
content: string
|
||||
feedback: WritingFeedback
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ToeicPart {
|
||||
id: number
|
||||
name: string
|
||||
|
||||
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
101
supabase/create/flash_card.sql
Normal file
@@ -0,0 +1,101 @@
|
||||
-- ============================================
|
||||
-- FLASHCARD SYSTEM
|
||||
-- ============================================
|
||||
|
||||
CREATE TABLE flashcard_list (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
total_words INT DEFAULT 0,
|
||||
created_by INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE flashcard_term (
|
||||
id SERIAL PRIMARY KEY,
|
||||
list_id INT NOT NULL REFERENCES flashcard_list(id) ON DELETE CASCADE,
|
||||
word VARCHAR(255) NOT NULL,
|
||||
part_of_speech VARCHAR(50), -- "adjective", "noun", "verb"...
|
||||
phonetic VARCHAR(100), -- "/kəˈmɜːʃəl/"
|
||||
definition TEXT, -- nghĩa tiếng Việt
|
||||
example TEXT, -- câu ví dụ (EN + VI)
|
||||
image_url VARCHAR(500),
|
||||
audio_tts_text VARCHAR(255), -- text để TTS (thường = word)
|
||||
audio_lang VARCHAR(10) DEFAULT 'en-US',
|
||||
display_order INT DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE user_flashcard_list (
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
list_id INT NOT NULL REFERENCES flashcard_list(id) ON DELETE CASCADE,
|
||||
enrolled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, list_id)
|
||||
);
|
||||
|
||||
CREATE TABLE user_flashcard_progress (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
term_id INT NOT NULL REFERENCES flashcard_term(id) ON DELETE CASCADE,
|
||||
list_id INT NOT NULL REFERENCES flashcard_list(id) ON DELETE CASCADE,
|
||||
|
||||
-- SRS fields
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'new',
|
||||
-- 'new' | 'learning' | 'known' | 'ignored'
|
||||
ease_factor DECIMAL(4,2) DEFAULT 1.0,
|
||||
-- 1.0=Dễ | 0.65=Trung bình | 0.1=Khó | -1=Đã biết/bỏ qua
|
||||
review_count INT DEFAULT 0,
|
||||
last_reviewed_at TIMESTAMP,
|
||||
next_review_at TIMESTAMP, -- SRS scheduling
|
||||
|
||||
UNIQUE (user_id, term_id, list_id)
|
||||
);
|
||||
|
||||
CREATE TABLE user_flashcard_session (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
list_id INT NOT NULL REFERENCES flashcard_list(id) ON DELETE CASCADE,
|
||||
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TIMESTAMP,
|
||||
terms_reviewed INT DEFAULT 0,
|
||||
terms_new INT DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE user_flashcard_review_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_id INT NOT NULL REFERENCES user_flashcard_session(id) ON DELETE CASCADE,
|
||||
term_id INT NOT NULL REFERENCES flashcard_term(id) ON DELETE CASCADE,
|
||||
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
action_value DECIMAL(4,2) NOT NULL, -- 1 | 0.65 | 0.1 | -1
|
||||
reviewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ============================================
|
||||
-- INDEXES
|
||||
-- ============================================
|
||||
|
||||
CREATE INDEX idx_term_list_id ON flashcard_term(list_id);
|
||||
CREATE INDEX idx_term_display_order ON flashcard_term(list_id, display_order);
|
||||
CREATE INDEX idx_progress_user ON user_flashcard_progress(user_id);
|
||||
CREATE INDEX idx_progress_next_review ON user_flashcard_progress(user_id, next_review_at);
|
||||
CREATE INDEX idx_progress_status ON user_flashcard_progress(user_id, list_id, status);
|
||||
CREATE INDEX idx_review_log_session ON user_flashcard_review_log(session_id);
|
||||
CREATE INDEX idx_enrolled_user ON user_flashcard_list(user_id);
|
||||
|
||||
-- 1. Cài đặt chế độ review — trên trang có nút "Cài đặt chế độ review".
|
||||
-- Cần lưu preferences của user như số từ mới mỗi ngày, thứ tự hiển thị (ngẫu nhiên hay tuần tự), hiển thị mặt trước là EN hay VI. Cần thêm table:
|
||||
|
||||
CREATE TABLE user_flashcard_settings (
|
||||
user_id INT REFERENCES users(id),
|
||||
list_id INT REFERENCES flashcard_list(id),
|
||||
daily_new_limit INT DEFAULT 20,
|
||||
shuffle BOOLEAN DEFAULT TRUE,
|
||||
front_side VARCHAR(10) DEFAULT 'word', -- 'word' | 'definition'
|
||||
show_all_terms BOOLEAN DEFAULT FALSE,
|
||||
-- Khi TRUE: hiển thị tất cả từ kể cả ignored
|
||||
-- Khi FALSE: từ ignored sẽ bị bỏ qua cho đến khi hết từ cần ôn
|
||||
PRIMARY KEY (user_id, list_id)
|
||||
);
|
||||
|
||||
-- cho user tạo list riêng
|
||||
ALTER TABLE flashcard_list ADD COLUMN is_public BOOLEAN DEFAULT TRUE;
|
||||
54
supabase/create/test.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
CREATE TABLE test (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
total_questions INT DEFAULT 0,
|
||||
duration_minutes INT DEFAULT 120,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE part (
|
||||
id SERIAL PRIMARY KEY,
|
||||
test_id INT NOT NULL REFERENCES test(id) ON DELETE CASCADE,
|
||||
part_number INT NOT NULL,
|
||||
title VARCHAR(100) NOT NULL,
|
||||
question_count INT DEFAULT 0,
|
||||
display_order INT DEFAULT 0,
|
||||
UNIQUE (test_id, part_number)
|
||||
);
|
||||
|
||||
CREATE TABLE tag (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE part_tag (
|
||||
part_id INT NOT NULL REFERENCES part(id) ON DELETE CASCADE,
|
||||
tag_id INT NOT NULL REFERENCES tag(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (part_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE question_group (
|
||||
id SERIAL PRIMARY KEY,
|
||||
part_id INT NOT NULL REFERENCES part(id) ON DELETE CASCADE,
|
||||
audio_url VARCHAR(500),
|
||||
image_url VARCHAR(500),
|
||||
passage_text TEXT,
|
||||
display_order INT DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE question (
|
||||
id SERIAL PRIMARY KEY,
|
||||
group_id INT NOT NULL REFERENCES question_group(id) ON DELETE CASCADE,
|
||||
question_number INT NOT NULL,
|
||||
question_text TEXT,
|
||||
display_order INT DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE answer_choice (
|
||||
id SERIAL PRIMARY KEY,
|
||||
question_id INT NOT NULL REFERENCES question(id) ON DELETE CASCADE,
|
||||
value CHAR(1) NOT NULL CHECK (value IN ('A', 'B', 'C', 'D')),
|
||||
label_text TEXT,
|
||||
is_correct BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||