leader board + setting

This commit is contained in:
2026-04-12 22:59:46 +07:00
parent 857341132c
commit 8de8b88a3d
32 changed files with 2302 additions and 15 deletions

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

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

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

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

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

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="userId" value="3d6f1b06:19d820f26b8:-7ffd" />
</MTProjectMetadataState>
</option>
</component>
</project>

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

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

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

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

159
Claude.md
View File

@@ -2,7 +2,7 @@
> File này cung cấp context đầy đủ cho Claude khi làm việc với dự án. > File này cung cấp context đầy đủ cho Claude khi làm việc với dự án.
> Cập nhật file này mỗi khi có quyết định kiến trúc mới. > Cập nhật file này mỗi khi có quyết định kiến trúc mới.
> **Last updated**: Merged all decisions — Phase 1 scaffold done, Phase 24 planned > **Last updated**: Phase 2 done thêm Phase 3 Retention & Monetization, đẩy Speaking AI và Full TOEIC sang Phase 4 & 5
--- ---
@@ -12,7 +12,7 @@
**Target users**: Sinh viên, người đi làm tại Việt Nam cần TOEIC, IELTS, hoặc học tiếng Anh tổng quát **Target users**: Sinh viên, người đi làm tại Việt Nam cần TOEIC, IELTS, hoặc học tiếng Anh tổng quát
**Focus chính**: TOEIC (mở rộng market), sau đó IELTS **Focus chính**: TOEIC (mở rộng market), sau đó IELTS
**Giai đoạn hiện tại**: Phase 1 — MVP, validate market **Giai đoạn hiện tại**: Phase 1 — MVP, validate market
**Roadmap**: 4 phases — MVP → Auth & Progress → Speaking AI → Full TOEIC **Roadmap**: 5 phases — MVP → Auth & Progress → Retention & Monetization → Speaking AI → Full TOEIC
--- ---
@@ -349,11 +349,136 @@ Evaluate the writing and return ONLY valid JSON:
--- ---
### PHASE 3 — Speaking AI ### PHASE 3 — Xu System & Gamification ← Tiếp theo
**Mục tiêu**: Tạo thói quen học hàng ngày, giữ chân user bằng Xu economy + gamification
**Platform**: Web only (không Flutter phase này)
**Trigger**: Phase 2 user đăng , cần convert sang returning users
---
#### Xu Economy — Trung tâm của Phase 3
```
Học hàng ngày / xem ads → kiếm Xu
Xu → dùng tính năng premium
Hết Xu → xem ads thêm
(nạp tiền thật → Phase 4)
```
**Kiếm Xu (miễn phí)**:
| Hành động | Xu nhận |
|---|---|
| Đăng lần đầu (welcome bonus) | 50 Xu |
| Hoàn thành daily goal | 10 Xu |
| Streak milestone (7 / 30 / 100 ngày) | 20 / 50 / 100 Xu |
| Xem rewarded ads trên web | 5 Xu / video (tối đa 5 video/ngày) |
**Dùng Xu**:
| Tính năng | Chi phí |
|---|---|
| Streak freeze (bảo vệ 1 ngày) | 20 Xu |
| Thêm 5 lượt AI Writing (GLM-4.7) | 30 Xu |
| 1 lượt AI Writing cao cấp (GPT-4o) | 15 Xu |
| Mở thêm bài thi khi hết giới hạn free | 10 Xu |
> ⚠️ Chưa có nạp Xu bằng tiền thật ở Phase 3 — chỉ kiếm qua học + ads.
> Nạp tiền thật (VNPay/MoMo) → Phase 4.
---
#### AI Model Tier
| Tier | Model | Free limit | Dùng Xu |
|---|---|---|---|
| Free | GLM-4 base | 3 lần/ngày | |
| Standard | GLM-4.7 | | 30 Xu / 5 lượt |
| Premium | GPT-4o / Claude | | 15 Xu / lượt |
---
#### Gamification
- **Streak hàng ngày**: học ít nhất 1 bài/ngày giữ chuỗi
- **XP points**: mỗi bài thi / flashcard / writing check XP
- **Cấp độ**: Beginner Bronze Silver Gold Master (theo XP tích luỹ)
- **Streak freeze**: dùng Xu bảo vệ streak khi bận hook mạnh nhất
- **Weekly goal**: đặt mục tiêu tuần, hoàn thành badge + thưởng Xu
#### Leaderboard
- Bảng xếp hạng tuần theo XP (reset mỗi tuần, không tích luỹ)
- Hiện top 10 + vị trí của bản thân
- Chia sẻ kết quả lên Facebook/Zalo viral loop tự nhiên
#### Nhắc nhở
- Browser push notification (web, không cần app)
- Giờ nhắc do user tự chọn
- Message nhân hoá: *"Streak 7 ngày của bạn sắp mất!"*
#### Lộ trình AI cá nhân hoá
- Phân tích kết quả thi suggest *"Tuần này tập trung Part 5 và 6"*
- Dashboard: *"Bạn yếu nhất ở Part 5 — luyện ngay"*
- Đặt ngày thi TOEIC đếm ngược + lịch ôn gợi ý
#### Web Ads
- Google AdSense: banner dưới trang + interstitial sau kết quả bài thi
- Rewarded video ads: xem để nhận Xu
- Không ads trong lúc đang làm bài
- User đủ Xu / premium không hiện ads (Phase 4)
---
#### DB Schema bổ sung
```sql
CREATE TABLE user_gamification (
user_id UUID REFERENCES users(id) PRIMARY KEY,
xp INT DEFAULT 0,
level TEXT DEFAULT 'beginner', -- beginner | bronze | silver | gold | master
streak INT DEFAULT 0,
longest_streak INT DEFAULT 0,
last_active DATE,
xu INT DEFAULT 50, -- welcome bonus
freeze_count INT DEFAULT 0
);
CREATE TABLE xu_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
type TEXT, -- earn_welcome | earn_daily | earn_streak | earn_ads | spend_freeze | spend_writing | spend_test
amount INT, -- dương = nhận, âm = tiêu
balance INT, -- số Xu sau giao dịch (để audit)
description TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE weekly_leaderboard (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
week_start DATE,
xp_earned INT DEFAULT 0,
rank INT
);
```
**Tech mới**:
- Google AdSense (banner + rewarded video)
- Browser Push Notification API
- Cron job: reset leaderboard mỗi tuần, check streak hàng ngày
**Không có ở Phase 3**:
- Nạp tiền thật (VNPay / MoMo) Phase 4
- Flutter / mobile app Phase 4
- Subscription / Premium plan Phase 4
**Timeline**: 56 tuần
---
### PHASE 4 — Speaking AI
**Mục tiêu**: Tăng differentiation, cover kỹ năng Speaking cho IELTS/TOEIC **Mục tiêu**: Tăng differentiation, cover kỹ năng Speaking cho IELTS/TOEIC
**Trigger**: Phase 2 ổn định, user quay lại đều đặn **Trigger**: Phase 3 ổn định, doanh thu đều
**Tính năng**: **Tính năng**:
- AI Speaking Coach: record giọng AI chấm phát âm + so sánh native speaker - AI Speaking Coach: record giọng AI chấm phát âm + so sánh native speaker
@@ -364,15 +489,15 @@ Evaluate the writing and return ONLY valid JSON:
**Tech mới**: **Tech mới**:
- Speech-to-text: Whisper API hoặc Google Speech-to-Text - Speech-to-text: Whisper API hoặc Google Speech-to-Text
- Text-to-speech: native audio - Text-to-speech: native audio
- WebRTC / MediaRecorder API (web) - WebRTC / MediaRecorder API (web) + Flutter audio recording
--- ---
### PHASE 4 — Full TOEIC Mock Test ### PHASE 5 — Full TOEIC Mock Test
**Mục tiêu**: Platform luyện TOEIC toàn diện chuẩn ETS **Mục tiêu**: Platform luyện TOEIC toàn diện chuẩn ETS
**Trigger**: Phase 3 xong, cần nội dung premium **Trigger**: Phase 4 xong, cần nội dung premium cao cấp hơn
**Tính năng**: **Tính năng**:
- Full TOEIC test chuẩn ETS: 200 câu, 120 phút - Full TOEIC test chuẩn ETS: 200 câu, 120 phút
@@ -389,20 +514,23 @@ Evaluate the writing and return ONLY valid JSON:
| Quyết định | do | | Quyết định | do |
|---|---| |---|---|
| Không auth Phase 1 | Giảm scope, validate market trước | | Không auth Phase 1 | Giảm scope, validate market trước |
| Email only Phase 2, không OAuth | Đơn giản nhất để build, OAuth Phase 3+ | | Email only Phase 2, không OAuth | Đơn giản nhất để build, OAuth Phase 4+ |
| Không xác thực email Phase 2 | MVP giảm friction đăng tối đa | | Không xác thực email Phase 2 | MVP giảm friction đăng tối đa |
| Chỉ 3 field đăng (tên/email/pass) | Friction thấp nhất, đủ để identify user | | Chỉ 3 field đăng (tên/email/pass) | Friction thấp nhất, đủ để identify user |
| Guest chỉ xem preview, không làm được | Buộc đăng để dùng, giúp thu thập user data | | Guest chỉ xem preview, không làm được | Buộc đăng để dùng, giúp thu thập user data |
| Không thanh toán Phase 2 | Hiểu behavior trước khi charge tiền | | Không thanh toán Phase 2 | Hiểu behavior trước khi charge tiền |
| Không Flutter Phase 2 | Web đã responsive, mobile app khi traction ràng | | Không Flutter Phase 2 | Web đã responsive, Flutter Phase 3 khi traction |
| Coins tên "Xu" | Gần gũi user VN hơn "Credits" hay "Points" |
| Pay-as-you-go thay subscription | User VN ít cam kết dài hạn, mua theo nhu cầu dễ convert hơn |
| AI model tier theo Xu | Tạo upsell tự nhiên user thấy feedback tốt hơn khi dùng model cao hơn |
| Rewarded ads mobile, banner ads web | Phù hợp từng platform mobile chịu video, web chịu banner |
| Supabase tạm Phase 1 | Ra nhanh hơn 23 tuần, schema chuẩn để migrate sau | | Supabase tạm Phase 1 | Ra nhanh hơn 23 tuần, schema chuẩn để migrate sau |
| GLM thay OpenAI/Claude | Rẻ hơn, OpenAI-compatible, swap dễ | | GLM thay OpenAI/Claude | Rẻ hơn, OpenAI-compatible, swap dễ |
| Desktop-first (không mobile-first) | Target TOEIC learner hay dùng máy tính | | Desktop-first | Target TOEIC learner hay dùng máy tính |
| TanStack Query + Zustand | Server state tách biệt client state ràng | | TanStack Query + Zustand | Server state tách biệt client state ràng |
| localStorage Phase 1 | Đủ cho MVP, không cần backend phức tạp |
| NestJS Phase 2 | Supabase đủ để validate, NestJS khi scale | | NestJS Phase 2 | Supabase đủ để validate, NestJS khi scale |
| Speaking AI Phase 3 | Cần infra ổn định trước khi làm realtime audio | | Speaking AI Phase 4 | Cần infra + monetization ổn định trước khi làm realtime audio |
| Full mock test Phase 4 | Cần content team + audio, không phải tech problem | | Full mock test Phase 5 | Cần content team + audio, không phải tech problem |
--- ---
@@ -414,6 +542,7 @@ Evaluate the writing and return ONLY valid JSON:
- **Desktop-first**: Design test trên 1280px trước, mobile sau - **Desktop-first**: Design test trên 1280px trước, mobile sau
- **YAGNI / KISS**: Không build thứ chưa cần, từng Phase giải quyết từng vấn đề - **YAGNI / KISS**: Không build thứ chưa cần, từng Phase giải quyết từng vấn đề
- **Schema chuẩn ngay từ đầu**: dùng Supabase, PostgreSQL schema phải production-ready - **Schema chuẩn ngay từ đầu**: dùng Supabase, PostgreSQL schema phải production-ready
- **Coins = "Xu"**: Dùng nhất quán trong code lẫn UI
--- ---
@@ -427,4 +556,6 @@ Evaluate the writing and return ONLY valid JSON:
| User mất progress (localStorage Phase 1) | 🟡 TB | Chấp nhận Phase 1, auth Phase 2 giải quyết | | User mất progress (localStorage Phase 1) | 🟡 TB | Chấp nhận Phase 1, auth Phase 2 giải quyết |
| Bản quyền đề TOEIC crawl | 🟡 TB | Seed nhanh, thay bằng nội dung tự soạn dần | | Bản quyền đề TOEIC crawl | 🟡 TB | Seed nhanh, thay bằng nội dung tự soạn dần |
| Supabase free tier limit | 🟢 Thấp | 500MB đủ Phase 1, migrate trước khi hit limit | | Supabase free tier limit | 🟢 Thấp | 500MB đủ Phase 1, migrate trước khi hit limit |
| Audio quality Phase 4 | 🟡 TB | Budget cho studio recording hoặc TTS premium | | AdSense bị block (adblocker) | 🟡 TB | Rewarded ads mobile lại, không phụ thuộc 1 nguồn |
| VNPay/MoMo integration phức tạp | 🟡 TB | Dùng payment gateway trung gian (Stripe VN, PayOS) |
| Audio quality Phase 5 | 🟡 TB | Budget cho studio recording hoặc TTS premium |

View File

@@ -3,9 +3,10 @@ import { cn } from '@/lib/utils'
const NAV_ITEMS = [ const NAV_ITEMS = [
{ to: '/', label: 'Home', icon: 'home', matchPrefix: '/', exact: true }, { to: '/', label: 'Home', icon: 'home', matchPrefix: '/', exact: true },
{ to: '/dashboard', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/dashboard', exact: false },
{ to: '/toeic', label: 'Luyện đề', icon: 'assignment', matchPrefix: '/toeic', 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: '/writing', label: 'Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false },
{ to: '/vocab', label: 'Từ vựng', icon: 'menu_book', matchPrefix: '/vocab', exact: false }, { to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false },
] ]
function isActive(pathname: string, prefix: string, exact: boolean) { function isActive(pathname: string, prefix: string, exact: boolean) {

View File

@@ -5,9 +5,11 @@ import { useAuthModalStore } from '@/store/auth-modal-store'
const NAV_ITEMS = [ const NAV_ITEMS = [
{ to: '/', label: 'Trang chủ', icon: 'home', matchPrefix: '/', exact: true }, { 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: '/toeic', label: 'Luyện đề TOEIC', icon: 'assignment', matchPrefix: '/toeic', exact: false },
{ to: '/writing', label: 'AI Writing', icon: 'edit_note', matchPrefix: '/writing', 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: '/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) { function isActive(pathname: string, prefix: string, exact: boolean) {

68
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,68 @@
import { Link } from '@tanstack/react-router'
import { useAuthStore } from '@/store/auth-store'
import { useAuthModalStore } from '@/store/auth-modal-store'
import { loadGamification } from './dashboard/gamification-store'
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'
export function Dashboard() {
const user = useAuthStore((s) => s.user)
const openModal = useAuthModalStore((s) => s.open)
if (!user) {
return (
<div className="px-4 lg:px-6 py-12 max-w-6xl mx-auto flex flex-col items-center 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 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>
)
}
const state = loadGamification()
return (
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto">
{/* Page header */}
<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>
{/* Hero stats */}
<StatsRow state={state} />
{/* Progress section */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5 mb-5">
<XpProgressCard state={state} />
<WeeklySection state={state} />
</div>
{/* Xu economy + leaderboard */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5">
<XuEconomyCard />
<LeaderboardCard />
</div>
{/* FAB */}
<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>
)
}

49
src/pages/Settings.tsx Normal file
View File

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

View File

@@ -0,0 +1,103 @@
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth-store'
// Phase 3: mock data until leaderboard DB table is live
const MOCK_LEADERS = [
{ rank: 1, name: 'Minh Anh', xp: 12450 },
{ rank: 2, name: 'Hoàng Nam', xp: 11200 },
{ rank: 3, name: 'Quỳnh Trang', xp: 10800 },
{ rank: 4, name: 'Đức Huy', xp: 9900 },
{ rank: 5, name: 'Phương Linh', xp: 9400 },
{ rank: 6, name: 'Trọng Khải', xp: 9100 },
]
const USER_RANK = { rank: 7, xp: 8950 }
function RankBadge({ rank }: { rank: number }) {
const gold = rank === 1
const silver = rank === 2
const bronze = rank === 3
return (
<div className={cn(
'w-8 h-8 flex items-center justify-center font-bold rounded-full text-xs',
gold ? 'bg-amber-200 text-amber-800' :
silver ? 'bg-slate-200 text-slate-700' :
bronze ? '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 userName = user?.name ?? 'Bạn'
const allRows = [
...MOCK_LEADERS,
{ rank: USER_RANK.rank, name: userName, xp: USER_RANK.xp, isMe: true },
].sort((a, b) => a.rank - b.rank)
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>
<div className="flex gap-2">
<span className="px-3 py-1 bg-blue-600 text-white rounded-full text-xs font-bold">Top 100</span>
<span className="px-3 py-1 bg-slate-100 text-slate-500 rounded-full text-xs font-bold">Bạn </span>
</div>
</div>
<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 Tổng</th>
</tr>
</thead>
<tbody>
{allRows.map((row) => {
const isMe = 'isMe' in row && row.isMe
return (
<tr
key={row.rank}
className={cn(
'transition-colors',
isMe ? 'bg-blue-50 border-2 border-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.name)}
</div>
<span className={cn('text-sm font-bold', isMe && 'text-blue-600')}>
{isMe ? `${row.name} (Bạn)` : row.name}
</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.xp.toLocaleString('vi-VN')} XP
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import type { GamificationState } from './gamification-store'
interface Props { state: GamificationState }
export function StatsRow({ state }: Props) {
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ố Xu</span>
<div className="flex items-center gap-3 mt-1">
<span className="text-4xl font-extrabold text-slate-800">{state.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">{state.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">Bạn thuộc top 5% người học!</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 {state.level}</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 {state.levelName}
</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>
)
}

View File

@@ -0,0 +1,70 @@
import { cn } from '@/lib/utils'
import type { GamificationState } from './gamification-store'
interface Props { state: GamificationState }
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
}
export function WeeklySection({ state }: Props) {
const todayIdx = getTodayIdx()
const progressPct = Math.round((state.weeklyCompleted / state.weeklyGoal) * 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 {state.weeklyGoal} bài học mỗi tuần</p>
</div>
<span className="text-2xl font-black text-green-600">
{state.weeklyCompleted}/{state.weeklyGoal}
</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 = state.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>
)
}

View File

@@ -0,0 +1,53 @@
import type { GamificationState } from './gamification-store'
interface Props { state: GamificationState }
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({ state }: Props) {
const percent = Math.round((state.xp / state.xpNextLevel) * 100)
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={state.xp} xpNext={state.xpNextLevel} />
<p className="text-sm text-slate-400 font-medium mt-4">
Chỉ còn {(state.xpNextLevel - state.xp).toLocaleString()} XP nữa đ đt Level {state.level + 1}!
</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>
)
}

View File

@@ -0,0 +1,46 @@
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>
)
}

View File

@@ -0,0 +1,64 @@
// 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[] // MonSun, 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 MonSun — 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(),
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
// Phase 3: Xu balance reads from localStorage until gamification DB is live
const XU_STORAGE_KEY = 'xu_balance'
const DEFAULT_XU = 50 // welcome bonus
function getXuBalance(): number {
const stored = localStorage.getItem(XU_STORAGE_KEY)
return stored ? parseInt(stored, 10) : DEFAULT_XU
}
const RECENT_TRANSACTIONS = [
{ label: 'Hoàn thành bài tập', amount: '+10 Xu', icon: 'add_circle' },
{ label: 'Đổi Streak Freeze', amount: '-20 Xu', icon: 'ac_unit' },
]
export function XuWalletCard() {
const balance = getXuBalance()
return (
<section className="col-span-12 md:col-span-4 bg-blue-600 text-white rounded-xl p-6 relative overflow-hidden flex flex-col justify-between shadow-sm">
{/* Decorative blobs */}
<div className="absolute -right-6 -top-6 w-28 h-28 bg-white/10 rounded-full blur-3xl pointer-events-none" />
<div className="absolute -left-6 -bottom-6 w-28 h-28 bg-blue-400/30 rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10">
<p className="text-xs font-bold uppercase tracking-widest opacity-70 mb-1"> Xu của bạn</p>
<div className="text-4xl font-extrabold tracking-tight">
{balance.toLocaleString('vi-VN')} Xu
</div>
</div>
<div className="relative z-10 mt-5 space-y-2.5">
{RECENT_TRANSACTIONS.map((t) => (
<div
key={t.label}
className="bg-white/10 backdrop-blur-md rounded-lg px-3 py-2.5 flex items-center justify-between text-xs"
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>{t.icon}</span>
<span>{t.label}</span>
</div>
<span className="font-bold">{t.amount}</span>
</div>
))}
</div>
</section>
)
}

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

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

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

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

View File

@@ -58,3 +58,45 @@ export interface User {
email: string email: string
name: string name: string
} }
// Phase 3 — Gamification
export type UserLevel = 'beginner' | 'bronze' | 'silver' | 'gold' | 'master'
export type XuTransactionType =
| 'earn_welcome'
| 'earn_daily'
| 'earn_streak'
| 'earn_ads'
| 'spend_freeze'
| 'spend_writing'
| 'spend_test'
export interface UserGamification {
userId: string
xp: number
level: UserLevel
streak: number
longestStreak: number
lastActive: string | null // DATE as ISO string
xu: number
freezeCount: number
createdAt: string
}
export interface XuTransaction {
id: string
userId: string
type: XuTransactionType
amount: number
balance: number
description: string | null
createdAt: string
}
export interface WeeklyLeaderboardEntry {
id: string
userId: string
weekStart: string
xpEarned: number
rank: number | null
}

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -0,0 +1,119 @@
-- Migration 002: Gamification — Xu system, XP, streak, leaderboard
-- Run in Supabase Dashboard → SQL Editor (after 001_user_progress.sql)
-- ============================================================
-- User gamification state (one row per user)
-- ============================================================
CREATE TABLE IF NOT EXISTS user_gamification (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
xp INT NOT NULL DEFAULT 0,
level TEXT NOT NULL DEFAULT 'beginner'
CHECK (level IN ('beginner', 'bronze', 'silver', 'gold', 'master')),
streak INT NOT NULL DEFAULT 0,
longest_streak INT NOT NULL DEFAULT 0,
last_active DATE,
xu INT NOT NULL DEFAULT 50, -- welcome bonus
freeze_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
-- ============================================================
-- Xu transaction log (immutable audit trail)
-- ============================================================
CREATE TABLE IF NOT EXISTS xu_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
type TEXT NOT NULL
CHECK (type IN (
'earn_welcome', 'earn_daily', 'earn_streak', 'earn_ads',
'spend_freeze', 'spend_writing', 'spend_test'
)),
amount INT NOT NULL, -- positive = earn, negative = spend
balance INT NOT NULL, -- xu balance after this transaction
description TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_xu_transactions_user_date
ON xu_transactions(user_id, created_at DESC);
-- ============================================================
-- Weekly leaderboard (reset every week)
-- ============================================================
CREATE TABLE IF NOT EXISTS weekly_leaderboard (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
week_start DATE NOT NULL,
xp_earned INT NOT NULL DEFAULT 0,
rank INT,
UNIQUE (user_id, week_start)
);
CREATE INDEX IF NOT EXISTS idx_leaderboard_week_xp
ON weekly_leaderboard(week_start, xp_earned DESC);
-- ============================================================
-- Row Level Security
-- ============================================================
-- user_gamification
ALTER TABLE user_gamification ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can read own gamification"
ON user_gamification FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own gamification"
ON user_gamification FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own gamification"
ON user_gamification FOR UPDATE
USING (auth.uid() = user_id);
-- xu_transactions (immutable — no UPDATE/DELETE)
ALTER TABLE xu_transactions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can read own xu transactions"
ON xu_transactions FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own xu transactions"
ON xu_transactions FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- weekly_leaderboard (public read, own write)
ALTER TABLE weekly_leaderboard ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can read leaderboard"
ON weekly_leaderboard FOR SELECT
USING (true);
CREATE POLICY "Users can insert own leaderboard"
ON weekly_leaderboard FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own leaderboard"
ON weekly_leaderboard FOR UPDATE
USING (auth.uid() = user_id);
-- ============================================================
-- Trigger: auto-create gamification row on new user signup
-- ============================================================
CREATE OR REPLACE FUNCTION handle_new_user_gamification()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO user_gamification (user_id, xu)
VALUES (NEW.id, 50);
INSERT INTO xu_transactions (user_id, type, amount, balance, description)
VALUES (NEW.id, 'earn_welcome', 50, 50, 'Welcome bonus');
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created_gamification
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION handle_new_user_gamification();