leader board + setting
This commit is contained in:
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
8
.idea/english.iml
generated
Normal file
8
.idea/english.iml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
10
.idea/material_theme_project_new.xml
generated
Normal file
10
.idea/material_theme_project_new.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/english.iml" filepath="$PROJECT_DIR$/.idea/english.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
159
Claude.md
159
Claude.md
@@ -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 2–4 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 có user đăng ký, cần convert sang returning users
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Xu Economy — Trung tâm của Phase 3
|
||||||
|
|
||||||
|
```
|
||||||
|
Học hàng ngày / xem ads → kiếm Xu
|
||||||
|
↓
|
||||||
|
Xu → dùng tính năng premium
|
||||||
|
↓
|
||||||
|
Hết Xu → xem ads thêm
|
||||||
|
(nạp tiền thật → Phase 4)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kiếm Xu (miễn phí)**:
|
||||||
|
| Hành động | Xu nhận |
|
||||||
|
|---|---|
|
||||||
|
| Đăng ký lần đầu (welcome bonus) | 50 Xu |
|
||||||
|
| Hoàn thành daily goal | 10 Xu |
|
||||||
|
| Streak milestone (7 / 30 / 100 ngày) | 20 / 50 / 100 Xu |
|
||||||
|
| Xem rewarded ads trên web | 5 Xu / video (tối đa 5 video/ngày) |
|
||||||
|
|
||||||
|
**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 cá nhân hoá: *"Streak 7 ngày của bạn sắp mất!"*
|
||||||
|
|
||||||
|
#### Lộ trình AI cá nhân hoá
|
||||||
|
- Phân tích kết quả thi → suggest *"Tuần này tập trung Part 5 và 6"*
|
||||||
|
- Dashboard: *"Bạn yếu nhất ở Part 5 — luyện ngay"*
|
||||||
|
- Đặt ngày thi TOEIC → đếm ngược + lịch ôn gợi ý
|
||||||
|
|
||||||
|
#### Web Ads
|
||||||
|
- Google AdSense: banner dưới trang + interstitial sau kết quả bài thi
|
||||||
|
- Rewarded video ads: xem để nhận Xu
|
||||||
|
- Không ads trong lúc đang làm bài
|
||||||
|
- User có đủ Xu / premium → không hiện ads (Phase 4)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### DB Schema bổ sung
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_gamification (
|
||||||
|
user_id UUID REFERENCES users(id) PRIMARY KEY,
|
||||||
|
xp INT DEFAULT 0,
|
||||||
|
level TEXT DEFAULT 'beginner', -- beginner | bronze | silver | gold | master
|
||||||
|
streak INT DEFAULT 0,
|
||||||
|
longest_streak INT DEFAULT 0,
|
||||||
|
last_active DATE,
|
||||||
|
xu INT DEFAULT 50, -- welcome bonus
|
||||||
|
freeze_count INT DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE xu_transactions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id),
|
||||||
|
type TEXT, -- earn_welcome | earn_daily | earn_streak | earn_ads | spend_freeze | spend_writing | spend_test
|
||||||
|
amount INT, -- dương = nhận, âm = tiêu
|
||||||
|
balance INT, -- số Xu sau giao dịch (để audit)
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE weekly_leaderboard (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id),
|
||||||
|
week_start DATE,
|
||||||
|
xp_earned INT DEFAULT 0,
|
||||||
|
rank INT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tech mới**:
|
||||||
|
- Google AdSense (banner + rewarded video)
|
||||||
|
- Browser Push Notification API
|
||||||
|
- Cron job: reset leaderboard mỗi tuần, check streak hàng ngày
|
||||||
|
|
||||||
|
**Không có ở Phase 3**:
|
||||||
|
- ❌ Nạp tiền thật (VNPay / MoMo) → Phase 4
|
||||||
|
- ❌ Flutter / mobile app → Phase 4
|
||||||
|
- ❌ Subscription / Premium plan → Phase 4
|
||||||
|
|
||||||
|
**Timeline**: 5–6 tuần
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PHASE 4 — Speaking AI
|
||||||
|
|
||||||
**Mục tiêu**: Tăng differentiation, cover kỹ năng Speaking cho IELTS/TOEIC
|
**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, có 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 | Lý do |
|
| Quyết định | Lý 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 ký tối đa |
|
| Không xác thực email Phase 2 | MVP — giảm friction đăng ký tối đa |
|
||||||
| Chỉ 3 field đăng ký (tên/email/pass) | Friction thấp nhất, đủ để identify user |
|
| Chỉ 3 field đăng ký (tên/email/pass) | Friction thấp nhất, đủ để identify user |
|
||||||
| Guest chỉ xem preview, không làm được | Buộc đăng ký để dùng, giúp thu thập user data |
|
| Guest chỉ xem preview, không làm được | Buộc đăng ký để dùng, giúp thu thập user data |
|
||||||
| Không thanh toán Phase 2 | Hiểu behavior trước khi charge tiền |
|
| Không thanh toán Phase 2 | Hiểu behavior trước khi charge tiền |
|
||||||
| Không Flutter Phase 2 | Web đã responsive, mobile app khi có traction rõ ràng |
|
| Không Flutter Phase 2 | Web đã responsive, Flutter Phase 3 khi có traction |
|
||||||
|
| Coins tên "Xu" | Gần gũi user VN hơn "Credits" hay "Points" |
|
||||||
|
| Pay-as-you-go thay subscription | User VN ít cam kết dài hạn, mua theo nhu cầu dễ convert hơn |
|
||||||
|
| AI model tier theo Xu | Tạo upsell tự nhiên — user thấy feedback tốt hơn khi dùng model cao hơn |
|
||||||
|
| Rewarded ads mobile, banner ads web | Phù hợp từng platform — mobile chịu video, web chịu banner |
|
||||||
| Supabase tạm Phase 1 | Ra nhanh hơn 2–3 tuần, schema chuẩn để migrate sau |
|
| Supabase tạm Phase 1 | Ra nhanh hơn 2–3 tuần, schema chuẩn để migrate sau |
|
||||||
| GLM thay OpenAI/Claude | Rẻ hơn, OpenAI-compatible, swap dễ |
|
| 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õ ràng |
|
| TanStack Query + Zustand | Server state tách biệt client state rõ 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 và test trên 1280px trước, mobile sau
|
- **Desktop-first**: Design và test trên 1280px trước, mobile sau
|
||||||
- **YAGNI / KISS**: Không build thứ chưa cần, từng Phase giải quyết từng vấn đề
|
- **YAGNI / KISS**: Không build thứ chưa cần, từng Phase giải quyết từng vấn đề
|
||||||
- **Schema chuẩn ngay từ đầu**: Dù dùng Supabase, PostgreSQL schema phải production-ready
|
- **Schema chuẩn ngay từ đầu**: Dù dùng Supabase, PostgreSQL schema phải production-ready
|
||||||
|
- **Coins = "Xu"**: Dùng nhất quán trong code lẫn UI
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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 bù lại, không phụ thuộc 1 nguồn |
|
||||||
|
| VNPay/MoMo integration phức tạp | 🟡 TB | Dùng payment gateway trung gian (Stripe VN, PayOS) |
|
||||||
|
| Audio quality Phase 5 | 🟡 TB | Budget cho studio recording hoặc TTS premium |
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
68
src/pages/Dashboard.tsx
Normal 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 và bảng xếp hạng của bạn.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => openModal('login')}
|
||||||
|
className="mt-2 px-6 py-2.5 bg-blue-600 text-white rounded-full font-bold text-sm hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Đăng nhập
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
49
src/pages/Settings.tsx
Normal 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 để cá nhân hoá mục tiêu học tập, cài đặt thông báo và quản lý tài khoản.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => openModal('login')}
|
||||||
|
className="mt-2 px-6 py-2.5 bg-blue-600 text-white rounded-full font-bold text-sm hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Đăng nhập
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 lg:px-6 py-6 max-w-5xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">Cài đặt</h1>
|
||||||
|
<p className="text-slate-400 text-sm">Quản lý hồ sơ, mục tiêu học tập và thông báo.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-12 gap-5">
|
||||||
|
<ProfileCard />
|
||||||
|
<XuWalletCard />
|
||||||
|
<DailyGoalCard />
|
||||||
|
<ExamDateCard />
|
||||||
|
<NotificationsCard />
|
||||||
|
<AccountCard />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
src/pages/dashboard/LeaderboardCard.tsx
Normal file
103
src/pages/dashboard/LeaderboardCard.tsx
Normal 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 bè</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
src/pages/dashboard/StatsRow.tsx
Normal file
49
src/pages/dashboard/StatsRow.tsx
Normal 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ố dư 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
70
src/pages/dashboard/WeeklySection.tsx
Normal file
70
src/pages/dashboard/WeeklySection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
src/pages/dashboard/XpProgressCard.tsx
Normal file
53
src/pages/dashboard/XpProgressCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
src/pages/dashboard/XuEconomyCard.tsx
Normal file
46
src/pages/dashboard/XuEconomyCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
src/pages/dashboard/gamification-store.ts
Normal file
64
src/pages/dashboard/gamification-store.ts
Normal 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[] // Mon–Sun, true = completed
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEYS = {
|
||||||
|
xu: 'xu_balance',
|
||||||
|
streak: 'gamification_streak',
|
||||||
|
xp: 'gamification_xp',
|
||||||
|
level: 'gamification_level',
|
||||||
|
weeklyCompleted: 'gamification_weekly_completed',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNum(key: string, fallback: number) {
|
||||||
|
const v = localStorage.getItem(key)
|
||||||
|
return v ? parseInt(v, 10) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLevelName(level: number): string {
|
||||||
|
if (level >= 40) return 'Master'
|
||||||
|
if (level >= 20) return 'Gold'
|
||||||
|
if (level >= 10) return 'Silver'
|
||||||
|
if (level >= 5) return 'Bronze'
|
||||||
|
return 'Beginner'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekActivity(): boolean[] {
|
||||||
|
// Days Mon–Sun — mark days up to (but not including) today as done if streak is active
|
||||||
|
const today = new Date().getDay() // 0=Sun, 1=Mon ... 6=Sat
|
||||||
|
const streak = getNum(KEYS.streak, 14)
|
||||||
|
// Convert to Mon=0 index
|
||||||
|
const todayIdx = today === 0 ? 6 : today - 1
|
||||||
|
const activity = Array(7).fill(false)
|
||||||
|
for (let i = 0; i < Math.min(todayIdx, streak, 7); i++) {
|
||||||
|
activity[i] = true
|
||||||
|
}
|
||||||
|
return activity
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadGamification(): GamificationState {
|
||||||
|
const level = getNum(KEYS.level, 14)
|
||||||
|
const xp = getNum(KEYS.xp, 1200)
|
||||||
|
|
||||||
|
return {
|
||||||
|
xu: getNum(KEYS.xu, 50),
|
||||||
|
streak: getNum(KEYS.streak, 14),
|
||||||
|
xp,
|
||||||
|
xpNextLevel: 1600, // hardcoded for Phase 3 demo
|
||||||
|
level,
|
||||||
|
levelName: getLevelName(level),
|
||||||
|
weeklyCompleted: getNum(KEYS.weeklyCompleted, 3),
|
||||||
|
weeklyGoal: 5,
|
||||||
|
weekActivity: getWeekActivity(),
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/pages/settings/AccountCard.tsx
Normal file
116
src/pages/settings/AccountCard.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function AccountCard() {
|
||||||
|
const logout = useAuthStore((s) => s.logout)
|
||||||
|
const [changingPw, setChangingPw] = useState(false)
|
||||||
|
const [pw, setPw] = useState({ current: '', next: '', confirm: '' })
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||||
|
|
||||||
|
async function changePassword() {
|
||||||
|
if (pw.next !== pw.confirm) { setMsg('Mật khẩu xác nhận không khớp.'); return }
|
||||||
|
if (pw.next.length < 6) { setMsg('Mật khẩu phải có ít nhất 6 ký tự.'); return }
|
||||||
|
setSaving(true)
|
||||||
|
setMsg('')
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.updateUser({ password: pw.next })
|
||||||
|
if (error) throw error
|
||||||
|
setMsg('Đổi mật khẩu thành công!')
|
||||||
|
setChangingPw(false)
|
||||||
|
setPw({ current: '', next: '', confirm: '' })
|
||||||
|
} catch {
|
||||||
|
setMsg('Không thể đổi mật khẩu. Thử lại sau.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAccount() {
|
||||||
|
// Supabase doesn't support self-delete via client SDK — log out and show message
|
||||||
|
await logout()
|
||||||
|
alert('Vui lòng liên hệ hỗ trợ để xóa tài khoản.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="col-span-12 bg-white rounded-xl p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-bold mb-5 flex items-center gap-2 text-slate-800">
|
||||||
|
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>security</span>
|
||||||
|
Tài khoản & Bảo mật
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Change password */}
|
||||||
|
<div className="flex items-center justify-between py-4 border-b border-slate-100">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-800 text-sm">Mật khẩu</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Cập nhật mật khẩu để bảo mật tài khoản</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setChangingPw(!changingPw); setMsg('') }}
|
||||||
|
className="px-5 py-2 rounded-full border border-slate-200 text-sm font-bold hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
Đổi mật khẩu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{changingPw && (
|
||||||
|
<div className="py-4 border-b border-slate-100 space-y-3">
|
||||||
|
{(['next', 'confirm'] as const).map((field) => (
|
||||||
|
<input
|
||||||
|
key={field}
|
||||||
|
type="password"
|
||||||
|
placeholder={field === 'next' ? 'Mật khẩu mới' : 'Xác nhận mật khẩu mới'}
|
||||||
|
value={pw[field]}
|
||||||
|
onChange={(e) => setPw((p) => ({ ...p, [field]: e.target.value }))}
|
||||||
|
className="w-full max-w-sm border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={changePassword}
|
||||||
|
disabled={saving}
|
||||||
|
className={cn('px-5 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold hover:bg-blue-700 transition-colors', saving && 'opacity-50')}
|
||||||
|
>
|
||||||
|
{saving ? 'Đang lưu...' : 'Lưu'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setChangingPw(false); setMsg('') }} className="px-4 py-2 text-slate-400 text-sm">
|
||||||
|
Huỷ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{msg && <p className={cn('text-sm', msg.includes('thành công') ? 'text-green-600' : 'text-red-600')}>{msg}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Danger zone */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<p className="text-xs font-bold text-red-500 uppercase tracking-wider mb-3">Khu vực nguy hiểm</p>
|
||||||
|
<div className="flex items-center justify-between p-4 bg-red-50 rounded-xl border border-red-100">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-red-600 text-sm">Xóa tài khoản</p>
|
||||||
|
<p className="text-xs text-red-400 mt-0.5">Hành động này không thể hoàn tác. Toàn bộ dữ liệu học tập sẽ bị mất.</p>
|
||||||
|
</div>
|
||||||
|
{confirmDelete ? (
|
||||||
|
<div className="flex gap-2 ml-4 flex-shrink-0">
|
||||||
|
<button onClick={deleteAccount} className="px-4 py-2 bg-red-600 text-white rounded-full text-sm font-bold hover:bg-red-700 transition-colors">
|
||||||
|
Xác nhận
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setConfirmDelete(false)} className="px-4 py-2 text-slate-500 text-sm">
|
||||||
|
Huỷ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
className="ml-4 flex-shrink-0 px-5 py-2 bg-red-600 text-white rounded-full text-sm font-bold shadow-sm hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Xóa tài khoản
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
src/pages/settings/DailyGoalCard.tsx
Normal file
56
src/pages/settings/DailyGoalCard.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const GOAL_OPTIONS = [
|
||||||
|
{ value: '10', label: '10p', sublabel: 'Dễ dàng' },
|
||||||
|
{ value: '20', label: '20p', sublabel: 'Tiêu chuẩn' },
|
||||||
|
{ value: '30', label: '30p', sublabel: 'Thử thách' },
|
||||||
|
{ value: '60', label: '1h', sublabel: 'Chuyên sâu' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const XP_MAP: Record<string, number> = { '10': 20, '20': 50, '30': 80, '60': 120 }
|
||||||
|
const STORAGE_KEY = 'settings_daily_goal'
|
||||||
|
|
||||||
|
export function DailyGoalCard() {
|
||||||
|
const [goal, setGoal] = useState<string>(() => localStorage.getItem(STORAGE_KEY) ?? '20')
|
||||||
|
|
||||||
|
function handleSelect(value: string) {
|
||||||
|
setGoal(value)
|
||||||
|
localStorage.setItem(STORAGE_KEY, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="col-span-12 md:col-span-6 bg-white rounded-xl p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-bold mb-5 flex items-center gap-2 text-slate-800">
|
||||||
|
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>target</span>
|
||||||
|
Mục tiêu hàng ngày
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{GOAL_OPTIONS.map((opt) => {
|
||||||
|
const active = goal === opt.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => handleSelect(opt.value)}
|
||||||
|
className={cn(
|
||||||
|
'p-4 rounded-xl border-2 text-center transition-all',
|
||||||
|
active
|
||||||
|
? 'border-blue-600 bg-blue-50 text-blue-600'
|
||||||
|
: 'border-slate-100 bg-slate-50 text-slate-700 hover:border-slate-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('block text-sm font-bold', active && 'text-blue-600')}>{opt.label}</span>
|
||||||
|
<span className={cn('text-xs', active ? 'text-blue-500' : 'text-slate-400')}>{opt.sublabel}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 p-3.5 rounded-xl bg-blue-50 flex items-center justify-center gap-2 text-blue-600 font-bold text-sm">
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18, fontVariationSettings: "'FILL' 1" }}>stars</span>
|
||||||
|
+{XP_MAP[goal]} XP mỗi ngày
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
src/pages/settings/ExamDateCard.tsx
Normal file
86
src/pages/settings/ExamDateCard.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'settings_exam_date'
|
||||||
|
|
||||||
|
function getDaysUntil(dateStr: string): number {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
const target = new Date(dateStr)
|
||||||
|
target.setHours(0, 0, 0, 0)
|
||||||
|
return Math.ceil((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVi(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return d.toLocaleDateString('vi-VN', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExamDateCard() {
|
||||||
|
const [examDate, setExamDate] = useState<string>(() => localStorage.getItem(STORAGE_KEY) ?? '')
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [input, setInput] = useState(examDate)
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
if (!input) return
|
||||||
|
setExamDate(input)
|
||||||
|
localStorage.setItem(STORAGE_KEY, input)
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = examDate ? getDaysUntil(examDate) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="col-span-12 md:col-span-6 bg-white rounded-xl p-6 shadow-sm flex flex-col">
|
||||||
|
<h2 className="text-lg font-bold mb-5 flex items-center gap-2 text-slate-800">
|
||||||
|
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>calendar_month</span>
|
||||||
|
Ngày thi TOEIC
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col justify-center">
|
||||||
|
{examDate && days !== null ? (
|
||||||
|
<>
|
||||||
|
<div className="bg-gradient-to-br from-blue-600 to-blue-500 rounded-xl p-6 text-white text-center shadow-lg shadow-blue-200">
|
||||||
|
<p className="text-sm font-medium opacity-80 mb-1">Đếm ngược kỳ thi</p>
|
||||||
|
<p className="text-4xl font-extrabold tracking-tight">
|
||||||
|
{days > 0 ? `Còn ${days} ngày` : days === 0 ? 'Hôm nay!' : 'Đã qua'}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 inline-flex items-center gap-1.5 px-3 py-1 bg-white/20 rounded-full text-xs">
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>event</span>
|
||||||
|
<span>{formatVi(examDate)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setInput(examDate); setEditing(true) }}
|
||||||
|
className="mt-4 w-full py-2.5 rounded-full border border-blue-200 text-blue-600 font-bold text-sm hover:bg-blue-50 transition-colors"
|
||||||
|
>
|
||||||
|
Thay đổi ngày thi
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<span className="material-symbols-outlined text-slate-300 mb-2" style={{ fontSize: 48 }}>event_upcoming</span>
|
||||||
|
<p className="text-slate-400 text-sm mb-4">Chưa đặt ngày thi</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing || !examDate ? (
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
min={new Date().toISOString().split('T')[0]}
|
||||||
|
className="flex-1 border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
|
||||||
|
/>
|
||||||
|
<button onClick={save} disabled={!input} className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold disabled:opacity-40 hover:bg-blue-700 transition-colors">
|
||||||
|
Lưu
|
||||||
|
</button>
|
||||||
|
{editing && (
|
||||||
|
<button onClick={() => setEditing(false)} className="px-3 py-2 text-slate-400 text-sm">Huỷ</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
src/pages/settings/NotificationsCard.tsx
Normal file
101
src/pages/settings/NotificationsCard.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface NotifPrefs {
|
||||||
|
daily: boolean
|
||||||
|
streak: boolean
|
||||||
|
weekly: boolean
|
||||||
|
leaderboard: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'settings_notifications'
|
||||||
|
const TIME_KEY = 'settings_notif_time'
|
||||||
|
|
||||||
|
const DEFAULT_PREFS: NotifPrefs = { daily: true, streak: true, weekly: false, leaderboard: true }
|
||||||
|
|
||||||
|
function loadPrefs(): NotifPrefs {
|
||||||
|
try {
|
||||||
|
return { ...DEFAULT_PREFS, ...JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') }
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_PREFS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToggleProps {
|
||||||
|
checked: boolean
|
||||||
|
onChange: (v: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toggle({ checked, onChange }: ToggleProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={cn(
|
||||||
|
'relative w-11 h-6 rounded-full transition-colors flex-shrink-0',
|
||||||
|
checked ? 'bg-blue-600' : 'bg-slate-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
'absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform',
|
||||||
|
checked ? 'translate-x-5' : 'translate-x-0.5',
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationsCard() {
|
||||||
|
const [prefs, setPrefs] = useState<NotifPrefs>(loadPrefs)
|
||||||
|
const [time, setTime] = useState(() => localStorage.getItem(TIME_KEY) ?? '20:00')
|
||||||
|
|
||||||
|
function toggle(key: keyof NotifPrefs) {
|
||||||
|
const next = { ...prefs, [key]: !prefs[key] }
|
||||||
|
setPrefs(next)
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTime(v: string) {
|
||||||
|
setTime(v)
|
||||||
|
localStorage.setItem(TIME_KEY, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: { key: keyof NotifPrefs; label: string; desc: string }[] = [
|
||||||
|
{ key: 'daily', label: 'Nhắc nhở hàng ngày', desc: 'Tùy chỉnh thời gian học mỗi ngày' },
|
||||||
|
{ key: 'streak', label: 'Cảnh báo chuỗi học tập', desc: 'Không bao giờ bỏ lỡ Streak của bạn' },
|
||||||
|
{ key: 'weekly', label: 'Nhắc nhở mục tiêu tuần', desc: 'Theo dõi tiến độ học tập hàng tuần' },
|
||||||
|
{ key: 'leaderboard', label: 'Cập nhật bảng xếp hạng', desc: 'Biết ngay khi ai đó vượt qua bạn' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="col-span-12 bg-white rounded-xl p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-bold mb-6 flex items-center gap-2 text-slate-800">
|
||||||
|
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>notifications_active</span>
|
||||||
|
Cài đặt thông báo
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-10 gap-y-6">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.key} className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-800 text-sm">{item.label}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">{item.desc}</p>
|
||||||
|
{item.key === 'daily' && prefs.daily && (
|
||||||
|
<div className="mt-2 inline-flex items-center gap-1.5 px-2.5 py-1 bg-slate-100 rounded-lg text-xs font-semibold text-slate-600">
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 13 }}>schedule</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => saveTime(e.target.value)}
|
||||||
|
className="bg-transparent outline-none text-xs font-semibold w-16"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Toggle checked={prefs[item.key]} onChange={() => toggle(item.key)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
126
src/pages/settings/ProfileCard.tsx
Normal file
126
src/pages/settings/ProfileCard.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function ProfileCard() {
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
const [editingName, setEditingName] = useState(false)
|
||||||
|
const [editingEmail, setEditingEmail] = useState(false)
|
||||||
|
const [nameInput, setNameInput] = useState(user?.name ?? '')
|
||||||
|
const [emailInput, setEmailInput] = useState(user?.email ?? '')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function saveName() {
|
||||||
|
if (!nameInput.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const { error: err } = await supabase.auth.updateUser({ data: { name: nameInput.trim() } })
|
||||||
|
if (err) throw err
|
||||||
|
setEditingName(false)
|
||||||
|
} catch {
|
||||||
|
setError('Không thể lưu tên. Thử lại sau.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEmail() {
|
||||||
|
if (!emailInput.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const { error: err } = await supabase.auth.updateUser({ email: emailInput.trim() })
|
||||||
|
if (err) throw err
|
||||||
|
setEditingEmail(false)
|
||||||
|
} catch {
|
||||||
|
setError('Không thể lưu email. Thử lại sau.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initials = (user?.name ?? 'U').charAt(0).toUpperCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="col-span-12 md:col-span-8 bg-white rounded-xl p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-bold mb-5 flex items-center gap-2 text-slate-800">
|
||||||
|
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>person</span>
|
||||||
|
Hồ sơ cá nhân
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<div className="w-20 h-20 rounded-full bg-blue-600 flex items-center justify-center text-white text-2xl font-bold">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fields */}
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
{/* Name */}
|
||||||
|
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-slate-400 font-semibold uppercase tracking-wider">Họ và tên</p>
|
||||||
|
{editingName ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={nameInput}
|
||||||
|
onChange={(e) => setNameInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') setEditingName(false) }}
|
||||||
|
className="mt-0.5 text-base font-semibold w-full border border-blue-300 rounded-lg px-2 py-0.5 outline-none focus:ring-2 focus:ring-blue-200"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-base font-semibold text-slate-800 mt-0.5">{user?.name ?? '—'}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editingName ? (
|
||||||
|
<div className="flex gap-2 ml-3">
|
||||||
|
<button onClick={saveName} disabled={saving} className={cn('text-blue-600 font-bold text-sm', saving && 'opacity-50')}>
|
||||||
|
{saving ? 'Lưu...' : 'Lưu'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditingName(false)} className="text-slate-400 text-sm">Huỷ</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setEditingName(true)} className="ml-3 text-blue-600 font-bold text-sm hover:underline">Chỉnh sửa</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-slate-400 font-semibold uppercase tracking-wider">Email</p>
|
||||||
|
{editingEmail ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="email"
|
||||||
|
value={emailInput}
|
||||||
|
onChange={(e) => setEmailInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') saveEmail(); if (e.key === 'Escape') setEditingEmail(false) }}
|
||||||
|
className="mt-0.5 text-base font-semibold w-full border border-blue-300 rounded-lg px-2 py-0.5 outline-none focus:ring-2 focus:ring-blue-200"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-base font-semibold text-slate-800 mt-0.5">{user?.email ?? '—'}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editingEmail ? (
|
||||||
|
<div className="flex gap-2 ml-3">
|
||||||
|
<button onClick={saveEmail} disabled={saving} className={cn('text-blue-600 font-bold text-sm', saving && 'opacity-50')}>
|
||||||
|
{saving ? 'Lưu...' : 'Lưu'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditingEmail(false)} className="text-slate-400 text-sm">Huỷ</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setEditingEmail(true)} className="ml-3 text-blue-600 font-bold text-sm hover:underline">Chỉnh sửa</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
src/pages/settings/XuWalletCard.tsx
Normal file
47
src/pages/settings/XuWalletCard.tsx
Normal 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">Ví Xu của bạn</p>
|
||||||
|
<div className="text-4xl font-extrabold tracking-tight">
|
||||||
|
{balance.toLocaleString('vi-VN')} Xu
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 mt-5 space-y-2.5">
|
||||||
|
{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
6
src/routes/dashboard.tsx
Normal 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
6
src/routes/settings.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { Settings } from '@/pages/Settings'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/settings')({
|
||||||
|
component: Settings,
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
68
stitch-exports/dashboard/DESIGN.md
Normal file
68
stitch-exports/dashboard/DESIGN.md
Normal 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
|
||||||
419
stitch-exports/dashboard/design.html
Normal file
419
stitch-exports/dashboard/design.html
Normal 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&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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>
|
||||||
BIN
stitch-exports/dashboard/design.png
Normal file
BIN
stitch-exports/dashboard/design.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
71
stitch-exports/preferences/DESIGN.md
Normal file
71
stitch-exports/preferences/DESIGN.md
Normal 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
|
||||||
346
stitch-exports/preferences/design.html
Normal file
346
stitch-exports/preferences/design.html
Normal 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&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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 & 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>
|
||||||
BIN
stitch-exports/preferences/design.png
Normal file
BIN
stitch-exports/preferences/design.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
119
supabase/migrations/002_gamification.sql
Normal file
119
supabase/migrations/002_gamification.sql
Normal 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();
|
||||||
Reference in New Issue
Block a user