diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/english.iml b/.idea/english.iml new file mode 100644 index 0000000..c956989 --- /dev/null +++ b/.idea/english.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml new file mode 100644 index 0000000..766912b --- /dev/null +++ b/.idea/material_theme_project_new.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..33f2143 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Claude.md b/Claude.md index 1198153..3d689b9 100644 --- a/Claude.md +++ b/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. > 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 **Focus chính**: TOEIC (mở rộng market), sau đó IELTS **Giai đoạn hiện tại**: Phase 1 — MVP, validate market -**Roadmap**: 4 phases — MVP → Auth & Progress → Speaking AI → Full TOEIC +**Roadmap**: 5 phases — MVP → Auth & Progress → Retention & Monetization → Speaking AI → Full TOEIC --- @@ -349,11 +349,136 @@ Evaluate the writing and return ONLY valid JSON: --- -### PHASE 3 — Speaking AI +### PHASE 3 — Xu System & Gamification ← Tiếp theo + +**Mục tiêu**: Tạo thói quen học hàng ngày, giữ chân user bằng Xu economy + gamification +**Platform**: Web only (không Flutter ở phase này) +**Trigger**: Phase 2 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 -**Trigger**: Phase 2 ổn định, user quay lại đều đặn +**Trigger**: Phase 3 ổn định, có doanh thu đều **Tính năng**: - AI Speaking Coach: record giọng → AI chấm phát âm + so sánh native speaker @@ -364,15 +489,15 @@ Evaluate the writing and return ONLY valid JSON: **Tech mới**: - Speech-to-text: Whisper API hoặc Google Speech-to-Text - Text-to-speech: native audio -- WebRTC / MediaRecorder API (web) +- WebRTC / MediaRecorder API (web) + Flutter audio recording --- -### PHASE 4 — Full TOEIC Mock Test +### PHASE 5 — Full TOEIC Mock Test **Mục tiêu**: Platform luyện TOEIC toàn diện chuẩn ETS -**Trigger**: Phase 3 xong, cần nội dung premium +**Trigger**: Phase 4 xong, cần nội dung premium cao cấp hơn **Tính năng**: - Full TOEIC test chuẩn ETS: 200 câu, 120 phút @@ -389,20 +514,23 @@ Evaluate the writing and return ONLY valid JSON: | Quyết định | Lý do | |---|---| | Không auth Phase 1 | Giảm scope, validate market trước | -| Email only Phase 2, không OAuth | Đơn giản nhất để build, OAuth Phase 3+ | +| Email only Phase 2, không OAuth | Đơn giản nhất để build, OAuth Phase 4+ | | Không xác thực email Phase 2 | MVP — giảm friction đăng ký tối đa | | Chỉ 3 field đăng ký (tên/email/pass) | Friction thấp nhất, đủ để identify user | | Guest chỉ xem preview, không làm được | Buộc đăng ký để dùng, giúp thu thập user data | | Không thanh toán Phase 2 | Hiểu behavior trước khi charge tiền | -| Không Flutter Phase 2 | Web đã responsive, 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 | | 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 | -| localStorage Phase 1 | Đủ cho MVP, không cần backend phức tạp | | NestJS Phase 2 | Supabase đủ để validate, NestJS khi scale | -| Speaking AI Phase 3 | Cần infra ổn định trước khi làm realtime audio | -| Full mock test Phase 4 | Cần content team + audio, không phải tech problem | +| Speaking AI Phase 4 | Cần infra + monetization ổn định trước khi làm realtime audio | +| Full mock test Phase 5 | Cần content team + audio, không phải tech problem | --- @@ -414,6 +542,7 @@ Evaluate the writing and return ONLY valid JSON: - **Desktop-first**: Design và test trên 1280px trước, mobile sau - **YAGNI / KISS**: Không build thứ chưa cần, từng Phase giải quyết từng vấn đề - **Schema chuẩn ngay từ đầu**: Dù dùng Supabase, PostgreSQL schema phải production-ready +- **Coins = "Xu"**: Dùng nhất quán trong code lẫn UI --- @@ -427,4 +556,6 @@ Evaluate the writing and return ONLY valid JSON: | User mất progress (localStorage Phase 1) | 🟡 TB | Chấp nhận Phase 1, auth Phase 2 giải quyết | | Bản quyền đề TOEIC crawl | 🟡 TB | Seed nhanh, thay bằng nội dung tự soạn dần | | Supabase free tier limit | 🟢 Thấp | 500MB đủ Phase 1, migrate trước khi hit limit | -| Audio quality Phase 4 | 🟡 TB | Budget cho studio recording hoặc TTS premium | \ No newline at end of file +| 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 | \ No newline at end of file diff --git a/src/components/MobileNav.tsx b/src/components/MobileNav.tsx index d9d1d2f..0896c05 100644 --- a/src/components/MobileNav.tsx +++ b/src/components/MobileNav.tsx @@ -3,9 +3,10 @@ import { cn } from '@/lib/utils' const NAV_ITEMS = [ { to: '/', label: 'Home', icon: 'home', matchPrefix: '/', exact: true }, + { to: '/dashboard', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/dashboard', exact: false }, { to: '/toeic', label: 'Luyện đề', icon: 'assignment', matchPrefix: '/toeic', exact: false }, { to: '/writing', label: 'Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false }, - { to: '/vocab', label: 'Từ vựng', icon: 'menu_book', matchPrefix: '/vocab', exact: false }, + { to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false }, ] function isActive(pathname: string, prefix: string, exact: boolean) { diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 19fab5e..c2e83a5 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -5,9 +5,11 @@ import { useAuthModalStore } from '@/store/auth-modal-store' const NAV_ITEMS = [ { to: '/', label: 'Trang chủ', icon: 'home', matchPrefix: '/', exact: true }, + { to: '/dashboard', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/dashboard', exact: false }, { to: '/toeic', label: 'Luyện đề TOEIC', icon: 'assignment', matchPrefix: '/toeic', exact: false }, { to: '/writing', label: 'AI Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false }, { to: '/vocab', label: 'Từ vựng', icon: 'menu_book', matchPrefix: '/vocab', exact: false }, + { to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false }, ] function isActive(pathname: string, prefix: string, exact: boolean) { diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..485ba26 --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -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 ( +
+ emoji_events +

Bảng thành tích

+

+ Đăng nhập để xem streak, XP, Xu và bảng xếp hạng của bạn. +

+ +
+ ) + } + + const state = loadGamification() + + return ( +
+ {/* Page header */} +
+

Bảng thành tích

+

Xin chào, {user.name} — tiếp tục chuỗi học tập nhé!

+
+ + {/* Hero stats */} + + + {/* Progress section */} +
+ + +
+ + {/* Xu economy + leaderboard */} +
+ + +
+ + {/* FAB */} + + play_arrow + +
+ ) +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx new file mode 100644 index 0000000..4efc8ad --- /dev/null +++ b/src/pages/Settings.tsx @@ -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 ( +
+ settings +

Cài đặt

+

+ Đă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. +

+ +
+ ) + } + + return ( +
+
+

Cài đặt

+

Quản lý hồ sơ, mục tiêu học tập và thông báo.

+
+ +
+ + + + + + +
+
+ ) +} diff --git a/src/pages/dashboard/LeaderboardCard.tsx b/src/pages/dashboard/LeaderboardCard.tsx new file mode 100644 index 0000000..b308bb7 --- /dev/null +++ b/src/pages/dashboard/LeaderboardCard.tsx @@ -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 ( +
+ {rank} +
+ ) +} + +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 ( +
+
+

Bảng xếp hạng tuần

+
+ Top 100 + Bạn bè +
+
+ + + + + + + + + + + {allRows.map((row) => { + const isMe = 'isMe' in row && row.isMe + return ( + + + + + + ) + })} + +
HạngNgười họcXP Tổng
+ + +
+
+ {initials(row.name)} +
+ + {isMe ? `${row.name} (Bạn)` : row.name} + +
+
+ + {row.xp.toLocaleString('vi-VN')} XP + +
+
+ ) +} diff --git a/src/pages/dashboard/StatsRow.tsx b/src/pages/dashboard/StatsRow.tsx new file mode 100644 index 0000000..b29a8a3 --- /dev/null +++ b/src/pages/dashboard/StatsRow.tsx @@ -0,0 +1,49 @@ +import type { GamificationState } from './gamification-store' + +interface Props { state: GamificationState } + +export function StatsRow({ state }: Props) { + return ( +
+ {/* Xu Balance */} +
+
+ Số dư Xu +
+ {state.xu.toLocaleString('vi-VN')} + monetization_on +
+

Dùng để mở tính năng premium

+
+ + {/* Streak */} +
+
+
+ Chuỗi học tập +
+ {state.streak} Ngày + local_fire_department +
+

Bạn thuộc top 5% người học!

+
+
+ + {/* Level */} +
+
+ Cấp độ +
+ Level {state.level} +
+ + Hạng {state.levelName} + +
+
+ military_tech +
+
+
+ ) +} diff --git a/src/pages/dashboard/WeeklySection.tsx b/src/pages/dashboard/WeeklySection.tsx new file mode 100644 index 0000000..a395fce --- /dev/null +++ b/src/pages/dashboard/WeeklySection.tsx @@ -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 ( +
+ {/* Weekly goal */} +
+
+
+

Mục tiêu tuần

+

Hoàn thành {state.weeklyGoal} bài học mỗi tuần

+
+ + {state.weeklyCompleted}/{state.weeklyGoal} + +
+
+
+
+
+ + {/* Weekly heatmap */} +
+

Lịch sử rèn luyện

+
+ {DAY_LABELS.map((label, i) => { + const isToday = i === todayIdx + const done = state.weekActivity[i] + const future = i > todayIdx + + return ( +
+ + {isToday ? 'H.Nay' : label} + + {isToday ? ( +
+ play_arrow +
+ ) : done ? ( +
+ check +
+ ) : ( +
+ )} +
+ ) + })} +
+
+
+ ) +} diff --git a/src/pages/dashboard/XpProgressCard.tsx b/src/pages/dashboard/XpProgressCard.tsx new file mode 100644 index 0000000..54c8992 --- /dev/null +++ b/src/pages/dashboard/XpProgressCard.tsx @@ -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 ( +
+ + + + +
+ {percent}% + + {xp.toLocaleString()} / {xpNext.toLocaleString()} XP + +
+
+ ) +} + +export function XpProgressCard({ state }: Props) { + const percent = Math.round((state.xp / state.xpNextLevel) * 100) + + return ( +
+

Tiến độ Cấp độ

+ + + +

+ Chỉ còn {(state.xpNextLevel - state.xp).toLocaleString()} XP nữa để đạt Level {state.level + 1}! +

+ + +
+ ) +} diff --git a/src/pages/dashboard/XuEconomyCard.tsx b/src/pages/dashboard/XuEconomyCard.tsx new file mode 100644 index 0000000..ff333b3 --- /dev/null +++ b/src/pages/dashboard/XuEconomyCard.tsx @@ -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 ( +
+

Cửa hàng Xu

+ +
+ {/* Earn */} +
+ Kiếm Xu +
+ {EARN_ITEMS.map((item) => ( +
+ {item.label} + {item.reward} +
+ ))} +
+
+ + {/* Spend */} +
+ Tiêu Xu +
+ {SPEND_ITEMS.map((item) => ( +
+ {item.label} + {item.cost} +
+ ))} +
+
+
+
+ ) +} diff --git a/src/pages/dashboard/gamification-store.ts b/src/pages/dashboard/gamification-store.ts new file mode 100644 index 0000000..56b3d06 --- /dev/null +++ b/src/pages/dashboard/gamification-store.ts @@ -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(), + } +} diff --git a/src/pages/settings/AccountCard.tsx b/src/pages/settings/AccountCard.tsx new file mode 100644 index 0000000..68db127 --- /dev/null +++ b/src/pages/settings/AccountCard.tsx @@ -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 ( +
+

+ security + Tài khoản & Bảo mật +

+ + {/* Change password */} +
+
+

Mật khẩu

+

Cập nhật mật khẩu để bảo mật tài khoản

+
+ +
+ + {changingPw && ( +
+ {(['next', 'confirm'] as const).map((field) => ( + 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" + /> + ))} +
+ + +
+ {msg &&

{msg}

} +
+ )} + + {/* Danger zone */} +
+

Khu vực nguy hiểm

+
+
+

Xóa tài khoản

+

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.

+
+ {confirmDelete ? ( +
+ + +
+ ) : ( + + )} +
+
+
+ ) +} diff --git a/src/pages/settings/DailyGoalCard.tsx b/src/pages/settings/DailyGoalCard.tsx new file mode 100644 index 0000000..ccf88e9 --- /dev/null +++ b/src/pages/settings/DailyGoalCard.tsx @@ -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 = { '10': 20, '20': 50, '30': 80, '60': 120 } +const STORAGE_KEY = 'settings_daily_goal' + +export function DailyGoalCard() { + const [goal, setGoal] = useState(() => localStorage.getItem(STORAGE_KEY) ?? '20') + + function handleSelect(value: string) { + setGoal(value) + localStorage.setItem(STORAGE_KEY, value) + } + + return ( +
+

+ target + Mục tiêu hàng ngày +

+ +
+ {GOAL_OPTIONS.map((opt) => { + const active = goal === opt.value + return ( + + ) + })} +
+ +
+ stars + +{XP_MAP[goal]} XP mỗi ngày +
+
+ ) +} diff --git a/src/pages/settings/ExamDateCard.tsx b/src/pages/settings/ExamDateCard.tsx new file mode 100644 index 0000000..d725714 --- /dev/null +++ b/src/pages/settings/ExamDateCard.tsx @@ -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(() => 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 ( +
+

+ calendar_month + Ngày thi TOEIC +

+ +
+ {examDate && days !== null ? ( + <> +
+

Đếm ngược kỳ thi

+

+ {days > 0 ? `Còn ${days} ngày` : days === 0 ? 'Hôm nay!' : 'Đã qua'} +

+
+ event + {formatVi(examDate)} +
+
+ + + ) : ( +
+ event_upcoming +

Chưa đặt ngày thi

+
+ )} + + {editing || !examDate ? ( +
+ 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" + /> + + {editing && ( + + )} +
+ ) : null} +
+
+ ) +} diff --git a/src/pages/settings/NotificationsCard.tsx b/src/pages/settings/NotificationsCard.tsx new file mode 100644 index 0000000..9d1ffd2 --- /dev/null +++ b/src/pages/settings/NotificationsCard.tsx @@ -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 ( + + ) +} + +export function NotificationsCard() { + const [prefs, setPrefs] = useState(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 ( +
+

+ notifications_active + Cài đặt thông báo +

+ +
+ {items.map((item) => ( +
+
+

{item.label}

+

{item.desc}

+ {item.key === 'daily' && prefs.daily && ( +
+ schedule + saveTime(e.target.value)} + className="bg-transparent outline-none text-xs font-semibold w-16" + /> +
+ )} +
+ toggle(item.key)} /> +
+ ))} +
+
+ ) +} diff --git a/src/pages/settings/ProfileCard.tsx b/src/pages/settings/ProfileCard.tsx new file mode 100644 index 0000000..824c28d --- /dev/null +++ b/src/pages/settings/ProfileCard.tsx @@ -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 ( +
+

+ person + Hồ sơ cá nhân +

+ +
+ {/* Avatar */} +
+
+ {initials} +
+
+ + {/* Fields */} +
+ {/* Name */} +
+
+

Họ và tên

+ {editingName ? ( + 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" + /> + ) : ( +

{user?.name ?? '—'}

+ )} +
+ {editingName ? ( +
+ + +
+ ) : ( + + )} +
+ + {/* Email */} +
+
+

Email

+ {editingEmail ? ( + 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" + /> + ) : ( +

{user?.email ?? '—'}

+ )} +
+ {editingEmail ? ( +
+ + +
+ ) : ( + + )} +
+
+
+ + {error &&

{error}

} +
+ ) +} diff --git a/src/pages/settings/XuWalletCard.tsx b/src/pages/settings/XuWalletCard.tsx new file mode 100644 index 0000000..32b51f0 --- /dev/null +++ b/src/pages/settings/XuWalletCard.tsx @@ -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 ( +
+ {/* Decorative blobs */} +
+
+ +
+

Ví Xu của bạn

+
+ {balance.toLocaleString('vi-VN')} Xu +
+
+ +
+ {RECENT_TRANSACTIONS.map((t) => ( +
+
+ {t.icon} + {t.label} +
+ {t.amount} +
+ ))} +
+
+ ) +} diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx new file mode 100644 index 0000000..caad190 --- /dev/null +++ b/src/routes/dashboard.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { Dashboard } from '@/pages/Dashboard' + +export const Route = createFileRoute('/dashboard')({ + component: Dashboard, +}) diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx new file mode 100644 index 0000000..3ee527c --- /dev/null +++ b/src/routes/settings.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { Settings } from '@/pages/Settings' + +export const Route = createFileRoute('/settings')({ + component: Settings, +}) diff --git a/src/types/index.ts b/src/types/index.ts index 7c57de5..9f66455 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -58,3 +58,45 @@ export interface User { email: string name: string } + +// Phase 3 — Gamification +export type UserLevel = 'beginner' | 'bronze' | 'silver' | 'gold' | 'master' + +export type XuTransactionType = + | 'earn_welcome' + | 'earn_daily' + | 'earn_streak' + | 'earn_ads' + | 'spend_freeze' + | 'spend_writing' + | 'spend_test' + +export interface UserGamification { + userId: string + xp: number + level: UserLevel + streak: number + longestStreak: number + lastActive: string | null // DATE as ISO string + xu: number + freezeCount: number + createdAt: string +} + +export interface XuTransaction { + id: string + userId: string + type: XuTransactionType + amount: number + balance: number + description: string | null + createdAt: string +} + +export interface WeeklyLeaderboardEntry { + id: string + userId: string + weekStart: string + xpEarned: number + rank: number | null +} diff --git a/stitch-exports/dashboard/DESIGN.md b/stitch-exports/dashboard/DESIGN.md new file mode 100644 index 0000000..9985e5d --- /dev/null +++ b/stitch-exports/dashboard/DESIGN.md @@ -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 + +- `
+
+
+ + + + \ No newline at end of file diff --git a/stitch-exports/dashboard/design.png b/stitch-exports/dashboard/design.png new file mode 100644 index 0000000..0f03dbd Binary files /dev/null and b/stitch-exports/dashboard/design.png differ diff --git a/stitch-exports/preferences/DESIGN.md b/stitch-exports/preferences/DESIGN.md new file mode 100644 index 0000000..1e8dd0f --- /dev/null +++ b/stitch-exports/preferences/DESIGN.md @@ -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 + +- `
+
+
+
+

Họ và tên

+

Minh Anh

+
+ +
+
+
+

Email

+

minhanh@example.com

+
+ +
+
+ + + +
+
+

Ví Xu của bạn

+
2,450 Xu
+
+
+
+
+add_circle +Hoàn thành bài tập +
++50 Xu +
+
+
+redeem +Đổi quà +
+-100 Xu +
+
+
+
+
+ +
+

+target + Mục tiêu hàng ngày +

+
+ + + + +
+
+stars ++50 XP mỗi ngày +
+
+ +
+

+calendar_month + Ngày thi TOEIC +

+
+
+

Đếm ngược kỳ thi

+

Còn 45 ngày

+
+event +25 Tháng 12, 2024 +
+
+ +
+
+ +
+

+notifications_active + Cài đặt thông báo +

+
+
+
+

Nhắc nhở hàng ngày

+

Tùy chỉnh thời gian học mỗi ngày

+
+schedule + 20:00 +
+
+ +
+
+
+

Cảnh báo chuỗi học tập

+

Không bao giờ bỏ lỡ Streak của bạn

+
+ +
+
+
+

Nhắc nhở mục tiêu tuần

+

Theo dõi tiến độ học tập hàng tuần

+
+ +
+
+
+

Cập nhật bảng xếp hạng

+

Biết ngay khi ai đó vượt qua bạn

+
+ +
+
+
+ +
+

+security + Tài khoản & Bảo mật +

+
+
+
+

Mật khẩu

+

Cập nhật mật khẩu để bảo mật tài khoản

+
+ +
+
+

Khu vực nguy hiểm

+
+
+

Xóa tài khoản

+

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.

+
+ +
+
+
+
+ + + \ No newline at end of file diff --git a/stitch-exports/preferences/design.png b/stitch-exports/preferences/design.png new file mode 100644 index 0000000..1263f4c Binary files /dev/null and b/stitch-exports/preferences/design.png differ diff --git a/supabase/migrations/002_gamification.sql b/supabase/migrations/002_gamification.sql new file mode 100644 index 0000000..4e95984 --- /dev/null +++ b/supabase/migrations/002_gamification.sql @@ -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();