refactor files
This commit is contained in:
116
src/features/settings/components/AccountCard.tsx
Normal file
116
src/features/settings/components/AccountCard.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function AccountCard() {
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const [changingPw, setChangingPw] = useState(false)
|
||||
const [pw, setPw] = useState({ current: '', next: '', confirm: '' })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
|
||||
async function changePassword() {
|
||||
if (pw.next !== pw.confirm) { setMsg('Mật khẩu xác nhận không khớp.'); return }
|
||||
if (pw.next.length < 6) { setMsg('Mật khẩu phải có ít nhất 6 ký tự.'); return }
|
||||
setSaving(true)
|
||||
setMsg('')
|
||||
try {
|
||||
const { error } = await supabase.auth.updateUser({ password: pw.next })
|
||||
if (error) throw error
|
||||
setMsg('Đổi mật khẩu thành công!')
|
||||
setChangingPw(false)
|
||||
setPw({ current: '', next: '', confirm: '' })
|
||||
} catch {
|
||||
setMsg('Không thể đổi mật khẩu. Thử lại sau.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
// Supabase doesn't support self-delete via client SDK — log out and show message
|
||||
await logout()
|
||||
alert('Vui lòng liên hệ hỗ trợ để xóa tài khoản.')
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="col-span-12 bg-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-lg font-bold mb-5 flex items-center gap-2 text-slate-800">
|
||||
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>security</span>
|
||||
Tài khoản & Bảo mật
|
||||
</h2>
|
||||
|
||||
{/* Change password */}
|
||||
<div className="flex items-center justify-between py-4 border-b border-slate-100">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-800 text-sm">Mật khẩu</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Cập nhật mật khẩu để bảo mật tài khoản</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setChangingPw(!changingPw); setMsg('') }}
|
||||
className="px-5 py-2 rounded-full border border-slate-200 text-sm font-bold hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Đổi mật khẩu
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{changingPw && (
|
||||
<div className="py-4 border-b border-slate-100 space-y-3">
|
||||
{(['next', 'confirm'] as const).map((field) => (
|
||||
<input
|
||||
key={field}
|
||||
type="password"
|
||||
placeholder={field === 'next' ? 'Mật khẩu mới' : 'Xác nhận mật khẩu mới'}
|
||||
value={pw[field]}
|
||||
onChange={(e) => setPw((p) => ({ ...p, [field]: e.target.value }))}
|
||||
className="w-full max-w-sm border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
|
||||
/>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={changePassword}
|
||||
disabled={saving}
|
||||
className={cn('px-5 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold hover:bg-blue-700 transition-colors', saving && 'opacity-50')}
|
||||
>
|
||||
{saving ? 'Đang lưu...' : 'Lưu'}
|
||||
</button>
|
||||
<button onClick={() => { setChangingPw(false); setMsg('') }} className="px-4 py-2 text-slate-400 text-sm">
|
||||
Huỷ
|
||||
</button>
|
||||
</div>
|
||||
{msg && <p className={cn('text-sm', msg.includes('thành công') ? 'text-green-600' : 'text-red-600')}>{msg}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="mt-6">
|
||||
<p className="text-xs font-bold text-red-500 uppercase tracking-wider mb-3">Khu vực nguy hiểm</p>
|
||||
<div className="flex items-center justify-between p-4 bg-red-50 rounded-xl border border-red-100">
|
||||
<div>
|
||||
<p className="font-semibold text-red-600 text-sm">Xóa tài khoản</p>
|
||||
<p className="text-xs text-red-400 mt-0.5">Hành động này không thể hoàn tác. Toàn bộ dữ liệu học tập sẽ bị mất.</p>
|
||||
</div>
|
||||
{confirmDelete ? (
|
||||
<div className="flex gap-2 ml-4 flex-shrink-0">
|
||||
<button onClick={deleteAccount} className="px-4 py-2 bg-red-600 text-white rounded-full text-sm font-bold hover:bg-red-700 transition-colors">
|
||||
Xác nhận
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(false)} className="px-4 py-2 text-slate-500 text-sm">
|
||||
Huỷ
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="ml-4 flex-shrink-0 px-5 py-2 bg-red-600 text-white rounded-full text-sm font-bold shadow-sm hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Xóa tài khoản
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
56
src/features/settings/components/DailyGoalCard.tsx
Normal file
56
src/features/settings/components/DailyGoalCard.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const GOAL_OPTIONS = [
|
||||
{ value: '10', label: '10p', sublabel: 'Dễ dàng' },
|
||||
{ value: '20', label: '20p', sublabel: 'Tiêu chuẩn' },
|
||||
{ value: '30', label: '30p', sublabel: 'Thử thách' },
|
||||
{ value: '60', label: '1h', sublabel: 'Chuyên sâu' },
|
||||
]
|
||||
|
||||
const XP_MAP: Record<string, number> = { '10': 20, '20': 50, '30': 80, '60': 120 }
|
||||
const STORAGE_KEY = 'settings_daily_goal'
|
||||
|
||||
export function DailyGoalCard() {
|
||||
const [goal, setGoal] = useState<string>(() => localStorage.getItem(STORAGE_KEY) ?? '20')
|
||||
|
||||
function handleSelect(value: string) {
|
||||
setGoal(value)
|
||||
localStorage.setItem(STORAGE_KEY, value)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="col-span-12 md:col-span-6 bg-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-lg font-bold mb-5 flex items-center gap-2 text-slate-800">
|
||||
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>target</span>
|
||||
Mục tiêu hàng ngày
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{GOAL_OPTIONS.map((opt) => {
|
||||
const active = goal === opt.value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
className={cn(
|
||||
'p-4 rounded-xl border-2 text-center transition-all',
|
||||
active
|
||||
? 'border-blue-600 bg-blue-50 text-blue-600'
|
||||
: 'border-slate-100 bg-slate-50 text-slate-700 hover:border-slate-200',
|
||||
)}
|
||||
>
|
||||
<span className={cn('block text-sm font-bold', active && 'text-blue-600')}>{opt.label}</span>
|
||||
<span className={cn('text-xs', active ? 'text-blue-500' : 'text-slate-400')}>{opt.sublabel}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 p-3.5 rounded-xl bg-blue-50 flex items-center justify-center gap-2 text-blue-600 font-bold text-sm">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18, fontVariationSettings: "'FILL' 1" }}>stars</span>
|
||||
+{XP_MAP[goal]} XP mỗi ngày
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
86
src/features/settings/components/ExamDateCard.tsx
Normal file
86
src/features/settings/components/ExamDateCard.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
const STORAGE_KEY = 'settings_exam_date'
|
||||
|
||||
function getDaysUntil(dateStr: string): number {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const target = new Date(dateStr)
|
||||
target.setHours(0, 0, 0, 0)
|
||||
return Math.ceil((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
function formatVi(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('vi-VN', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function ExamDateCard() {
|
||||
const [examDate, setExamDate] = useState<string>(() => localStorage.getItem(STORAGE_KEY) ?? '')
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [input, setInput] = useState(examDate)
|
||||
|
||||
function save() {
|
||||
if (!input) return
|
||||
setExamDate(input)
|
||||
localStorage.setItem(STORAGE_KEY, input)
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
const days = examDate ? getDaysUntil(examDate) : null
|
||||
|
||||
return (
|
||||
<section className="col-span-12 md:col-span-6 bg-white rounded-xl p-6 shadow-sm flex flex-col">
|
||||
<h2 className="text-lg font-bold mb-5 flex items-center gap-2 text-slate-800">
|
||||
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>calendar_month</span>
|
||||
Ngày thi TOEIC
|
||||
</h2>
|
||||
|
||||
<div className="flex-1 flex flex-col justify-center">
|
||||
{examDate && days !== null ? (
|
||||
<>
|
||||
<div className="bg-gradient-to-br from-blue-600 to-blue-500 rounded-xl p-6 text-white text-center shadow-lg shadow-blue-200">
|
||||
<p className="text-sm font-medium opacity-80 mb-1">Đếm ngược kỳ thi</p>
|
||||
<p className="text-4xl font-extrabold tracking-tight">
|
||||
{days > 0 ? `Còn ${days} ngày` : days === 0 ? 'Hôm nay!' : 'Đã qua'}
|
||||
</p>
|
||||
<div className="mt-3 inline-flex items-center gap-1.5 px-3 py-1 bg-white/20 rounded-full text-xs">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>event</span>
|
||||
<span>{formatVi(examDate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setInput(examDate); setEditing(true) }}
|
||||
className="mt-4 w-full py-2.5 rounded-full border border-blue-200 text-blue-600 font-bold text-sm hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
Thay đổi ngày thi
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-6">
|
||||
<span className="material-symbols-outlined text-slate-300 mb-2" style={{ fontSize: 48 }}>event_upcoming</span>
|
||||
<p className="text-slate-400 text-sm mb-4">Chưa đặt ngày thi</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing || !examDate ? (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
className="flex-1 border border-slate-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
|
||||
/>
|
||||
<button onClick={save} disabled={!input} className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold disabled:opacity-40 hover:bg-blue-700 transition-colors">
|
||||
Lưu
|
||||
</button>
|
||||
{editing && (
|
||||
<button onClick={() => setEditing(false)} className="px-3 py-2 text-slate-400 text-sm">Huỷ</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
101
src/features/settings/components/NotificationsCard.tsx
Normal file
101
src/features/settings/components/NotificationsCard.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NotifPrefs {
|
||||
daily: boolean
|
||||
streak: boolean
|
||||
weekly: boolean
|
||||
leaderboard: boolean
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'settings_notifications'
|
||||
const TIME_KEY = 'settings_notif_time'
|
||||
|
||||
const DEFAULT_PREFS: NotifPrefs = { daily: true, streak: true, weekly: false, leaderboard: true }
|
||||
|
||||
function loadPrefs(): NotifPrefs {
|
||||
try {
|
||||
return { ...DEFAULT_PREFS, ...JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') }
|
||||
} catch {
|
||||
return DEFAULT_PREFS
|
||||
}
|
||||
}
|
||||
|
||||
interface ToggleProps {
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange }: ToggleProps) {
|
||||
return (
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
'relative w-11 h-6 rounded-full transition-colors flex-shrink-0',
|
||||
checked ? 'bg-blue-600' : 'bg-slate-200',
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform',
|
||||
checked ? 'translate-x-5' : 'translate-x-0.5',
|
||||
)} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotificationsCard() {
|
||||
const [prefs, setPrefs] = useState<NotifPrefs>(loadPrefs)
|
||||
const [time, setTime] = useState(() => localStorage.getItem(TIME_KEY) ?? '20:00')
|
||||
|
||||
function toggle(key: keyof NotifPrefs) {
|
||||
const next = { ...prefs, [key]: !prefs[key] }
|
||||
setPrefs(next)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||
}
|
||||
|
||||
function saveTime(v: string) {
|
||||
setTime(v)
|
||||
localStorage.setItem(TIME_KEY, v)
|
||||
}
|
||||
|
||||
const items: { key: keyof NotifPrefs; label: string; desc: string }[] = [
|
||||
{ key: 'daily', label: 'Nhắc nhở hàng ngày', desc: 'Tùy chỉnh thời gian học mỗi ngày' },
|
||||
{ key: 'streak', label: 'Cảnh báo chuỗi học tập', desc: 'Không bao giờ bỏ lỡ Streak của bạn' },
|
||||
{ key: 'weekly', label: 'Nhắc nhở mục tiêu tuần', desc: 'Theo dõi tiến độ học tập hàng tuần' },
|
||||
{ key: 'leaderboard', label: 'Cập nhật bảng xếp hạng', desc: 'Biết ngay khi ai đó vượt qua bạn' },
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="col-span-12 bg-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-lg font-bold mb-6 flex items-center gap-2 text-slate-800">
|
||||
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>notifications_active</span>
|
||||
Cài đặt thông báo
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-10 gap-y-6">
|
||||
{items.map((item) => (
|
||||
<div key={item.key} className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-800 text-sm">{item.label}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{item.desc}</p>
|
||||
{item.key === 'daily' && prefs.daily && (
|
||||
<div className="mt-2 inline-flex items-center gap-1.5 px-2.5 py-1 bg-slate-100 rounded-lg text-xs font-semibold text-slate-600">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 13 }}>schedule</span>
|
||||
<input
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => saveTime(e.target.value)}
|
||||
className="bg-transparent outline-none text-xs font-semibold w-16"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Toggle checked={prefs[item.key]} onChange={() => toggle(item.key)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
126
src/features/settings/components/ProfileCard.tsx
Normal file
126
src/features/settings/components/ProfileCard.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function ProfileCard() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [editingEmail, setEditingEmail] = useState(false)
|
||||
const [nameInput, setNameInput] = useState(user?.name ?? '')
|
||||
const [emailInput, setEmailInput] = useState(user?.email ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function saveName() {
|
||||
if (!nameInput.trim()) return
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const { error: err } = await supabase.auth.updateUser({ data: { name: nameInput.trim() } })
|
||||
if (err) throw err
|
||||
setEditingName(false)
|
||||
} catch {
|
||||
setError('Không thể lưu tên. Thử lại sau.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEmail() {
|
||||
if (!emailInput.trim()) return
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const { error: err } = await supabase.auth.updateUser({ email: emailInput.trim() })
|
||||
if (err) throw err
|
||||
setEditingEmail(false)
|
||||
} catch {
|
||||
setError('Không thể lưu email. Thử lại sau.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const initials = (user?.name ?? 'U').charAt(0).toUpperCase()
|
||||
|
||||
return (
|
||||
<section className="col-span-12 md:col-span-8 bg-white rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-lg font-bold mb-5 flex items-center gap-2 text-slate-800">
|
||||
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 22 }}>person</span>
|
||||
Hồ sơ cá nhân
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Avatar */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-20 h-20 rounded-full bg-blue-600 flex items-center justify-center text-white text-2xl font-bold">
|
||||
{initials}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fields */}
|
||||
<div className="flex-1 space-y-3">
|
||||
{/* Name */}
|
||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-slate-400 font-semibold uppercase tracking-wider">Họ và tên</p>
|
||||
{editingName ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={nameInput}
|
||||
onChange={(e) => setNameInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') setEditingName(false) }}
|
||||
className="mt-0.5 text-base font-semibold w-full border border-blue-300 rounded-lg px-2 py-0.5 outline-none focus:ring-2 focus:ring-blue-200"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-base font-semibold text-slate-800 mt-0.5">{user?.name ?? '—'}</p>
|
||||
)}
|
||||
</div>
|
||||
{editingName ? (
|
||||
<div className="flex gap-2 ml-3">
|
||||
<button onClick={saveName} disabled={saving} className={cn('text-blue-600 font-bold text-sm', saving && 'opacity-50')}>
|
||||
{saving ? 'Lưu...' : 'Lưu'}
|
||||
</button>
|
||||
<button onClick={() => setEditingName(false)} className="text-slate-400 text-sm">Huỷ</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setEditingName(true)} className="ml-3 text-blue-600 font-bold text-sm hover:underline">Chỉnh sửa</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-slate-400 font-semibold uppercase tracking-wider">Email</p>
|
||||
{editingEmail ? (
|
||||
<input
|
||||
autoFocus
|
||||
type="email"
|
||||
value={emailInput}
|
||||
onChange={(e) => setEmailInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') saveEmail(); if (e.key === 'Escape') setEditingEmail(false) }}
|
||||
className="mt-0.5 text-base font-semibold w-full border border-blue-300 rounded-lg px-2 py-0.5 outline-none focus:ring-2 focus:ring-blue-200"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-base font-semibold text-slate-800 mt-0.5">{user?.email ?? '—'}</p>
|
||||
)}
|
||||
</div>
|
||||
{editingEmail ? (
|
||||
<div className="flex gap-2 ml-3">
|
||||
<button onClick={saveEmail} disabled={saving} className={cn('text-blue-600 font-bold text-sm', saving && 'opacity-50')}>
|
||||
{saving ? 'Lưu...' : 'Lưu'}
|
||||
</button>
|
||||
<button onClick={() => setEditingEmail(false)} className="text-slate-400 text-sm">Huỷ</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setEditingEmail(true)} className="ml-3 text-blue-600 font-bold text-sm hover:underline">Chỉnh sửa</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
49
src/features/settings/components/Settings.tsx
Normal file
49
src/features/settings/components/Settings.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||
import { ProfileCard } from './ProfileCard'
|
||||
import { XuWalletCard } from './XuWalletCard'
|
||||
import { DailyGoalCard } from './DailyGoalCard'
|
||||
import { ExamDateCard } from './ExamDateCard'
|
||||
import { NotificationsCard } from './NotificationsCard'
|
||||
import { AccountCard } from './AccountCard'
|
||||
|
||||
export function Settings() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const openModal = useAuthModalStore((s) => s.open)
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="px-4 lg:px-6 py-12 max-w-6xl mx-auto flex flex-col items-center justify-center gap-4 text-center">
|
||||
<span className="material-symbols-outlined text-slate-300" style={{ fontSize: 64 }}>settings</span>
|
||||
<h1 className="text-xl font-bold text-slate-700">Cài đặt</h1>
|
||||
<p className="text-slate-400 text-sm max-w-xs">
|
||||
Đăng nhập để cá nhân hoá mục tiêu học tập, cài đặt thông báo và quản lý tài khoản.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => openModal('login')}
|
||||
className="mt-2 px-6 py-2.5 bg-blue-600 text-white rounded-full font-bold text-sm hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Đăng nhập
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 lg:px-6 py-6 max-w-5xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">Cài đặt</h1>
|
||||
<p className="text-slate-400 text-sm">Quản lý hồ sơ, mục tiêu học tập và thông báo.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-5">
|
||||
<ProfileCard />
|
||||
<XuWalletCard />
|
||||
<DailyGoalCard />
|
||||
<ExamDateCard />
|
||||
<NotificationsCard />
|
||||
<AccountCard />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
src/features/settings/components/XuWalletCard.tsx
Normal file
68
src/features/settings/components/XuWalletCard.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useXuTransactions } from '@/hooks/use-gamification'
|
||||
import { useGamification } from '@/hooks/use-gamification'
|
||||
import type { XuTransactionType } from '@/types'
|
||||
|
||||
const TX_ICON: Record<XuTransactionType, string> = {
|
||||
earn_welcome: 'redeem',
|
||||
earn_daily: 'check_circle',
|
||||
earn_streak: 'local_fire_department',
|
||||
earn_ads: 'play_circle',
|
||||
spend_freeze: 'ac_unit',
|
||||
spend_writing: 'auto_fix_high',
|
||||
spend_test: 'quiz',
|
||||
}
|
||||
|
||||
export function XuWalletCard() {
|
||||
const { data: gam } = useGamification()
|
||||
const { data: txs, isLoading } = useXuTransactions(5)
|
||||
|
||||
const balance = gam?.xu ?? 0
|
||||
|
||||
return (
|
||||
<section className="col-span-12 md:col-span-4 bg-blue-600 text-white rounded-xl p-6 relative overflow-hidden flex flex-col justify-between shadow-sm">
|
||||
{/* Decorative blobs */}
|
||||
<div className="absolute -right-6 -top-6 w-28 h-28 bg-white/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute -left-6 -bottom-6 w-28 h-28 bg-blue-400/30 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<p className="text-xs font-bold uppercase tracking-widest opacity-70 mb-1">Ví Xu của bạn</p>
|
||||
<div className="text-4xl font-extrabold tracking-tight">
|
||||
{balance.toLocaleString('vi-VN')} Xu
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-5 space-y-2.5">
|
||||
{isLoading && (
|
||||
<>
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="h-10 bg-white/10 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isLoading && txs && txs.length === 0 && (
|
||||
<div className="bg-white/10 rounded-lg px-3 py-2.5 text-xs opacity-70 text-center">
|
||||
Chưa có giao dịch nào
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && txs && txs.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="bg-white/10 backdrop-blur-md rounded-lg px-3 py-2.5 flex items-center justify-between text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>
|
||||
{TX_ICON[t.type] ?? 'swap_horiz'}
|
||||
</span>
|
||||
<span className="truncate max-w-[120px]">{t.description ?? t.type}</span>
|
||||
</div>
|
||||
<span className={`font-bold ${t.amount > 0 ? 'text-green-300' : 'text-red-300'}`}>
|
||||
{t.amount > 0 ? `+${t.amount}` : t.amount} Xu
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user