diff --git a/client/src/App.jsx b/client/src/App.jsx index e1419a8..37c97a5 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,38 +1,63 @@ import { Outlet, createBrowserRouter, RouterProvider } from 'react-router-dom' import { Suspense } from 'react' import { lazy } from 'react' -import {HomeProvider} from './providers/HomeProvider' +import { HomeProvider } from './providers/HomeProvider' import AppProvider from './providers/AppProvider' import MainLayout from './components/layouts/MainLayout' import DetailProvider from './providers/DetailProvider' import { AuthProvider } from './providers/AuthProvider' import ScrollToTop from './utils/scrollToTop' import { ToastContainer } from 'react-toastify' +import { useAuth } from './hooks/useAuth.jsx' +import RequireLoginModal from './components/common/Modals/RequireLoginModal.jsx' const Home = lazy(() => import('./pages/Home')) const Login = lazy(() => import('./pages/Auth/Login/Login')) const Register = lazy(() => import('./pages/Auth/Register/Register')) -const ForgotPassword = lazy(() => import('./pages/Auth/ForgotPassword/ForgotPassword')) +const ForgotPassword = lazy( + () => import('./pages/Auth/ForgotPassword/ForgotPassword'), +) const MyList = lazy(() => import('./pages/MyList/MyList')) const Search = lazy(() => import('./pages/Search/Search')) const Movies = lazy(() => import('./pages/Movies')) const TVShows = lazy(() => import('./pages/TVShows')) const MediaDetails = lazy(() => import('./pages/MediaDetails/MediaDetails')) const MediaPlayer = lazy(() => import('./pages/MediaPlayer/MediaPlayer')) -const PremiumCheckout = lazy(() => import('./pages/PremiumCheckout/PremiumCheckout')) -const ProfileSelection = lazy(() => import('./pages/Profile/ProfileSelection')) -const ProfileManage = lazy(() => import('./pages/Profile/ProfileManage')) -const ProfileForm = lazy(() => import('./pages/Profile/ProfileForm')) +const PremiumCheckout = lazy( + () => import('./pages/PremiumCheckout/PremiumCheckout'), +) +const AccountSettings = lazy(() => import('./pages/Account/AccountSettings')) -const PageLoader = () =>
+const PageLoader = () => ( +
+) +// 1. Tạo component con để xử lý UI và Gọi hook useAuth hợp lệ +const AppContent = () => { + const { isLoginModalOpen, setIsLoginModalOpen } = useAuth() + + return ( + <> + + + + + {/* Modal nằm ở đây sẽ nhận được state từ AuthProvider toàn cục */} + setIsLoginModalOpen(false)} + message="Bạn cần đăng nhập để lưu phim vào danh sách yêu thích." + /> + + ) +} + +// 2. RootLayout đóng vai trò bọc Provider thiết lập môi trường const RootLayout = () => { return ( - - - + ) @@ -104,9 +129,7 @@ const router = createBrowserRouter([ path: '/movies', element: ( }> - - - + ), }, @@ -118,9 +141,16 @@ const router = createBrowserRouter([ ), }, + { + path: '/account', + element: ( + }> + + + ), + }, ], }, - { path: '/login', element: ( @@ -145,39 +175,6 @@ const router = createBrowserRouter([ ), }, - - { - path: '/profiles', - element: ( - }> - - - ), - }, - { - path: '/profiles/manage', - element: ( - }> - - - ), - }, - { - path: '/profiles/add', - element: ( - }> - - - ), - }, - { - path: '/profiles/edit/:id', - element: ( - }> - - - ), - }, ], }, ]) @@ -191,4 +188,3 @@ const App = () => { } export default App - diff --git a/client/src/api/userApi.js b/client/src/api/userApi.js new file mode 100644 index 0000000..a812dd1 --- /dev/null +++ b/client/src/api/userApi.js @@ -0,0 +1,25 @@ +import { apiClient } from './axiosClient' + +export const getUserProfileApi = () => + apiClient.get('/users/profile').then((res) => res.data.data) + +export const updateUserProfileApi = (data) => { + const formData = new FormData() + if (data.fullName !== undefined) formData.append('fullName', data.fullName) + if (data.phone !== undefined) formData.append('phone', data.phone) + if (data.dateOfBirth !== undefined) formData.append('dateOfBirth', data.dateOfBirth) + if (data.gender !== undefined) formData.append('gender', data.gender) + if (data.avatar !== undefined) formData.append('avatar', data.avatar) + + return apiClient.put('/users/profile', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }).then((res) => res.data.data) +} + +export const changePasswordApi = (data) => + apiClient.post('/users/change-password', data).then((res) => res.data.data) + +export const getSubscriptionHistoryApi = () => + apiClient.get('/users/subscription-history').then((res) => res.data.data) diff --git a/client/src/components/common/ActionButton/FavouriteButton/FavouriteButton.jsx b/client/src/components/common/ActionButton/FavouriteButton/FavouriteButton.jsx index 984ca45..edccb5f 100644 --- a/client/src/components/common/ActionButton/FavouriteButton/FavouriteButton.jsx +++ b/client/src/components/common/ActionButton/FavouriteButton/FavouriteButton.jsx @@ -8,8 +8,6 @@ import { fetchCollectionsApi, } from '../../../../api/collection.api.js' import { useAuth } from '../../../../hooks/useAuth.jsx' -import { useHome } from '../../../../contexts/HomeContext.jsx' - // THÊM prop variant vào đây (mặc định là 'detail') const FavouriteButton = ({ movie, variant = 'detail' }) => { @@ -17,16 +15,14 @@ const FavouriteButton = ({ movie, variant = 'detail' }) => { const isHome = location.pathname === '/' const queryClient = useQueryClient() const [isModalOpen, setIsModalOpen] = useState(false) - - const { isAuthenticated } = useAuth() - const { setIsLoginModalOpen } = useHome() + + const { isAuthenticated, isLoginModalOpen, setIsLoginModalOpen } = useAuth() const { data: collections = [], isLoading: isLoadingCollections } = useQuery({ queryKey: ['collections'], queryFn: fetchCollectionsApi, enabled: isAuthenticated, }) - const isLoved = useMemo(() => { if (!collections || collections.length === 0 || !movie?.id) return false @@ -64,6 +60,7 @@ const FavouriteButton = ({ movie, variant = 'detail' }) => { e.stopPropagation() if (!isAuthenticated) { setIsLoginModalOpen(true) + return } if (!movie?.id) { toast.error('Không tìm thấy thông tin phim!') @@ -120,8 +117,6 @@ const FavouriteButton = ({ movie, variant = 'detail' }) => {
)} - - {/* Modal chọn list */} {isModalOpen && (
diff --git a/client/src/components/common/AppBar/AppBar.jsx b/client/src/components/common/AppBar/AppBar.jsx index 28afbaf..2195a9f 100644 --- a/client/src/components/common/AppBar/AppBar.jsx +++ b/client/src/components/common/AppBar/AppBar.jsx @@ -6,9 +6,11 @@ import { useMyPremiumSubscription } from '../../../hooks/usePremium.jsx' import { useDebounce } from '../../../hooks/useDebounce.jsx' import { useSearch } from '../../../hooks/useSearch.jsx' import { createSlug } from '../../../utils/formatters.js' +import { useQueryClient } from '@tanstack/react-query' const AppBar = () => { const { user, isAuthenticated: isLogged, logout } = useAuth() + const queryClient = useQueryClient() const { data: premiumSubscription } = useMyPremiumSubscription({ enabled: isLogged, }) @@ -66,7 +68,16 @@ const AppBar = () => { setSearchTerm('') } } - + const handleLogoutClick = async () => { + try { + await logout() + queryClient.clear() + setIsUserMenuOpen(false) + navigate('/') + } catch (error) { + console.log("Lỗi khi đăng xuất: ", error) + } + } return (
@@ -238,7 +249,7 @@ const AppBar = () => { {/* Dropdown Menu */} {isUserMenuOpen && (
-
+
{getAvatarUrl() ? ( @@ -297,7 +308,7 @@ const AppBar = () => {
+ ) + })} + +
+ + {/* Main Content Area */} +
+ {renderContent()} +
+
+
+
+ ) +} diff --git a/client/src/pages/Account/PersonalInfo.jsx b/client/src/pages/Account/PersonalInfo.jsx new file mode 100644 index 0000000..3fbfc47 --- /dev/null +++ b/client/src/pages/Account/PersonalInfo.jsx @@ -0,0 +1,241 @@ +import { useState, useRef } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Camera, Loader, User } from 'lucide-react' +import { getUserProfileApi, updateUserProfileApi } from '../../api/userApi' +import { toast } from 'react-toastify' + +export default function PersonalInfo() { + const queryClient = useQueryClient() + const fileInputRef = useRef(null) + + const { data: user, isLoading: isUserLoading } = useQuery({ + queryKey: ['userProfile'], + queryFn: getUserProfileApi, + }) + + const [formData, setFormData] = useState({ + fullName: '', + phone: '', + dateOfBirth: '', + gender: 'MALE', + }) + + const [avatarPreview, setAvatarPreview] = useState(null) + const [selectedFile, setSelectedFile] = useState(null) + + const { mutate: updateProfile, isPending: isUpdating } = useMutation({ + mutationFn: (data) => updateUserProfileApi(data), + onSuccess: (data) => { + toast.success('Cập nhật thông tin thành công') + queryClient.invalidateQueries({ queryKey: ['userProfile'] }) + queryClient.invalidateQueries({ queryKey: ['currentUser'] }) + setSelectedFile(null) + setAvatarPreview(null) + }, + onError: (error) => { + const errorMsg = + error?.response?.data?.message || 'Cập nhật thông tin thất bại' + toast.error(errorMsg) + }, + }) + + const handleInputChange = (e) => { + const { name, value } = e.target + setFormData((prev) => ({ + ...prev, + [name]: value, + })) + } + + const handleAvatarClick = () => { + fileInputRef.current?.click() + } + + const handleAvatarChange = (e) => { + const file = e.target.files?.[0] + if (file) { + setSelectedFile(file) + const reader = new FileReader() + reader.onload = (event) => { + setAvatarPreview(event.target?.result) + } + reader.readAsDataURL(file) + } + } + + const handleSubmit = (e) => { + e.preventDefault() + const updateData = { + ...formData, + } + if (selectedFile) { + updateData.avatar = selectedFile + } + updateProfile(updateData) + } + + if (isUserLoading) { + return ( +
+ +
+ ) + } + + const displayAvatar = avatarPreview || user?.avatarUrl + const displayName = user?.fullName || '' + const displayPhone = user?.phone || '' + const displayDateOfBirth = user?.dateOfBirth + ? user.dateOfBirth.split('T')[0] + : '' + const displayGender = user?.gender || 'MALE' + + return ( +
+
+

Ảnh đại diện

+ +
+
+ {displayAvatar ? ( + <> + Avatar + + ) : ( + <> +
+ +
+ + )} +
+ +
+
+ + + +
+

Hình ảnh hồ sơ của bạn

+

+ Chấp nhận: JPG, PNG, GIF. Kích thước tối đa: 5MB +

+ +
+
+
+ +
+
+

+ Thông tin cá nhân +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+ ) +} diff --git a/client/src/pages/Account/ProfilesManagement.jsx b/client/src/pages/Account/ProfilesManagement.jsx new file mode 100644 index 0000000..1cd11c9 --- /dev/null +++ b/client/src/pages/Account/ProfilesManagement.jsx @@ -0,0 +1,268 @@ +import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Plus, Loader, Edit2, Trash2, User } from 'lucide-react' +import { + getProfilesApi, + createProfileApi, + deleteProfileApi, +} from '../../api/profileApi' +import { toast } from 'react-toastify' + +export default function ProfilesManagement() { + const queryClient = useQueryClient() + const [showModal, setShowModal] = useState(false) + const [profileName, setProfileName] = useState('') + const [profileType, setProfileType] = useState('ADULT') + const [deleteConfirm, setDeleteConfirm] = useState(null) + + const { data: profiles = [], isLoading } = useQuery({ + queryKey: ['userProfiles'], + queryFn: getProfilesApi, + }) + + const { mutate: createProfile, isPending: isCreating } = useMutation({ + mutationFn: (data) => createProfileApi(data), + onSuccess: () => { + toast.success('Tạo hồ sơ thành công') + queryClient.invalidateQueries({ queryKey: ['userProfiles'] }) + setShowModal(false) + setProfileName('') + setProfileType('ADULT') + }, + onError: (error) => { + const errorMsg = error?.response?.data?.message || 'Tạo hồ sơ thất bại' + toast.error(errorMsg) + }, + }) + + const { mutate: deleteProfile, isPending: isDeleting } = useMutation({ + mutationFn: (id) => deleteProfileApi(id), + onSuccess: () => { + toast.success('Xóa hồ sơ thành công') + queryClient.invalidateQueries({ queryKey: ['userProfiles'] }) + setDeleteConfirm(null) + }, + onError: (error) => { + const errorMsg = error?.response?.data?.message || 'Xóa hồ sơ thất bại' + toast.error(errorMsg) + }, + }) + + const handleCreateProfile = (e) => { + e.preventDefault() + if (!profileName.trim()) { + toast.error('Vui lòng nhập tên hồ sơ') + return + } + createProfile({ + name: profileName.trim(), + type: profileType, + }) + } + + const handleDelete = (id) => { + deleteProfile(id) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + const canAddMore = profiles.length < 5 + + return ( +
+
+
+

+ Hồ sơ xem chung ({profiles.length}/5) +

+

+ Tối đa 5 hồ sơ trên một tài khoản +

+
+ +
+ {profiles.map((profile) => ( +
+
+ {profile.avatarUrl ? ( + {profile.name} + ) : ( + <> +
+ +
+ + )} + +

+ {profile.name} +

+

+ {profile.type === 'KID' ? 'Trẻ em' : 'Người lớn'} +

+ +
+ + +
+
+
+ ))} + + {canAddMore && ( + + )} +
+
+ + {/* Modal Thêm Hồ Sơ */} + {showModal && ( +
+
+
+

+ Tạo hồ sơ mới +

+ +
+
+ + setProfileName(e.target.value)} + placeholder="Nhập tên hồ sơ" + maxLength={50} + className="w-full px-4 py-2.5 bg-slate-800/50 border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500/30 transition-colors duration-200" + disabled={isCreating} + /> +
+ +
+ + +
+ +
+ + +
+
+
+
+
+ )} + + {/* Modal Xác Nhận Xóa */} + {deleteConfirm && ( +
+
+
+

+ Xóa hồ sơ? +

+

+ Hành động này không thể hoàn tác. Hồ sơ này và tất cả dữ liệu + liên quan sẽ bị xóa vĩnh viễn. +

+ +
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/client/src/pages/Account/SecuritySettings.jsx b/client/src/pages/Account/SecuritySettings.jsx new file mode 100644 index 0000000..5d87cf8 --- /dev/null +++ b/client/src/pages/Account/SecuritySettings.jsx @@ -0,0 +1,239 @@ +import { useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { Eye, EyeOff, Loader } from 'lucide-react' +import { changePasswordApi } from '../../api/userApi' +import { toast } from 'react-toastify' + +export default function SecuritySettings() { + const [formData, setFormData] = useState({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + }) + + const [showPasswords, setShowPasswords] = useState({ + current: false, + new: false, + confirm: false, + }) + + const [errors, setErrors] = useState({}) + + const { mutate: changePassword, isPending: isChanging } = useMutation({ + mutationFn: (data) => changePasswordApi({ + currentPassword: data.currentPassword, + newPassword: data.newPassword, + }), + onSuccess: () => { + toast.success('Đổi mật khẩu thành công') + setFormData({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + }) + setErrors({}) + }, + onError: (error) => { + const errorMsg = error?.response?.data?.message || 'Đổi mật khẩu thất bại' + toast.error(errorMsg) + }, + }) + + const handleInputChange = (e) => { + const { name, value } = e.target + setFormData((prev) => ({ + ...prev, + [name]: value, + })) + setErrors((prev) => ({ + ...prev, + [name]: '', + })) + } + + const togglePasswordVisibility = (field) => { + setShowPasswords((prev) => ({ + ...prev, + [field]: !prev[field], + })) + } + + const validateForm = () => { + const newErrors = {} + + if (!formData.currentPassword.trim()) { + newErrors.currentPassword = 'Vui lòng nhập mật khẩu hiện tại' + } + + if (!formData.newPassword.trim()) { + newErrors.newPassword = 'Vui lòng nhập mật khẩu mới' + } else if (formData.newPassword.length < 6) { + newErrors.newPassword = 'Mật khẩu mới phải có ít nhất 6 ký tự' + } + + if (!formData.confirmPassword.trim()) { + newErrors.confirmPassword = 'Vui lòng xác nhận mật khẩu' + } else if (formData.newPassword !== formData.confirmPassword) { + newErrors.confirmPassword = 'Mật khẩu xác nhận không trùng khớp' + } + + if ( + formData.currentPassword && + formData.newPassword && + formData.currentPassword === formData.newPassword + ) { + newErrors.newPassword = 'Mật khẩu mới phải khác mật khẩu hiện tại' + } + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } + + const handleSubmit = (e) => { + e.preventDefault() + if (!validateForm()) return + changePassword(formData) + } + + return ( +
+
+

Bảo mật tài khoản

+

+ Cập nhật mật khẩu để bảo vệ tài khoản của bạn +

+ +
+
+ +
+ + +
+ {errors.currentPassword && ( +

{errors.currentPassword}

+ )} +
+ +
+ +
+ + +
+ {errors.newPassword && ( +

{errors.newPassword}

+ )} +
+ +
+ +
+ + +
+ {errors.confirmPassword && ( +

{errors.confirmPassword}

+ )} +
+ +
+ +
+
+
+ +
+

Các phiên đăng nhập khác

+

+ Nếu bạn không nhận ra một phiên đăng nhập, bạn có thể đăng xuất khỏi tất cả các phiên khác. +

+ +
+
+ ) +} diff --git a/client/src/pages/Account/SubscriptionHistory.jsx b/client/src/pages/Account/SubscriptionHistory.jsx new file mode 100644 index 0000000..1ffe021 --- /dev/null +++ b/client/src/pages/Account/SubscriptionHistory.jsx @@ -0,0 +1,187 @@ +import { useQuery } from '@tanstack/react-query' +import { Loader, Calendar, DollarSign, CheckCircle, AlertCircle } from 'lucide-react' +import { getSubscriptionHistoryApi } from '../../api/userApi' + +const formatCurrency = (amount) => { + return new Intl.NumberFormat('vi-VN', { + style: 'currency', + currency: 'VND', + }).format(amount) +} + +const formatDate = (dateString) => { + if (!dateString) return 'N/A' + const date = new Date(dateString) + return date.toLocaleDateString('vi-VN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) +} + +const getStatusBadgeColor = (status) => { + switch (status) { + case 'SUCCEEDED': + return 'bg-green-500/20 text-green-400 border-green-500/30' + case 'PENDING': + return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' + case 'FAILED': + return 'bg-red-500/20 text-red-400 border-red-500/30' + case 'REFUNDED': + return 'bg-blue-500/20 text-blue-400 border-blue-500/30' + default: + return 'bg-white/10 text-white/70 border-white/20' + } +} + +const getStatusIcon = (status) => { + switch (status) { + case 'SUCCEEDED': + return + case 'FAILED': + return + default: + return null + } +} + +export default function SubscriptionHistory() { + const { data: subscriptionData, isLoading } = useQuery({ + queryKey: ['subscriptionHistory'], + queryFn: getSubscriptionHistoryApi, + }) + + if (isLoading) { + return ( +
+ +
+ ) + } + + const { currentPlan, history = [] } = subscriptionData || {} + + return ( +
+ {currentPlan && ( +
+

Gói dịch vụ hiện tại

+ +
+
+

Gói

+

{currentPlan.name}

+
+ +
+

Giá

+

+ {formatCurrency(currentPlan.price)} +

+
+ +
+

Trạng thái

+
+
new Date() ? 'bg-green-500' : 'bg-red-500' + }`} /> +

+ {new Date(currentPlan.endAt) > new Date() ? 'Hoạt động' : 'Đã hết hạn'} +

+
+
+
+ + {currentPlan.endAt && ( +
+
+ + Hạn dùng +
+

+ {formatDate(currentPlan.endAt)} +

+
+ )} +
+ )} + +
+

Lịch sử giao dịch

+ + {history && history.length > 0 ? ( +
+ + + + + + + + + + + {history.map((payment) => ( + + + + + + + ))} + +
+ Mã giao dịch + + Ngày thanh toán + + Số tiền + + Trạng thái +
+

+ {payment.providerTransactionId || 'N/A'} +

+
+

+ {formatDate(payment.paidAt || payment.createdAt)} +

+
+

+ {formatCurrency(payment.amount)} +

+
+
+
+ {getStatusIcon(payment.status)} + + {payment.status === 'SUCCEEDED' + ? 'Thành công' + : payment.status === 'PENDING' + ? 'Đang xử lý' + : payment.status === 'FAILED' + ? 'Thất bại' + : 'Hoàn tiền'} + +
+
+
+
+ ) : ( +
+ +

Chưa có giao dịch nào

+
+ )} +
+
+ ) +} diff --git a/client/src/pages/Home/Home.jsx b/client/src/pages/Home/Home.jsx index 6a075ae..7d8f73d 100644 --- a/client/src/pages/Home/Home.jsx +++ b/client/src/pages/Home/Home.jsx @@ -3,10 +3,8 @@ import MediaBanner from '../../components/common/Movies/MediaBanner/MediaBanner' import MediaCollection from '../../components/common/Movies/MediaCollection/MediaCollection' import { useHome } from '../../contexts/HomeContext' import HomeSkeleton from './HomeSkeleton' -import RequireLoginModal from '../../components/common/Modals/RequireLoginModal.jsx' import { useState } from 'react' - const Home = () => { const { isLoading, @@ -16,8 +14,6 @@ const Home = () => { activeMediaId, mediasPopular, mediasReleased, - isLoginModalOpen, - setIsLoginModalOpen, } = useHome() const [isOpen, setIsOpen] = useState(false) @@ -66,13 +62,7 @@ const Home = () => { )} - {isOpen && setIsOpen(!isOpen)} />} - {/* Modal đăng nhập */} - setIsLoginModalOpen(false)} - message="Bạn cần đăng nhập để lưu phim vào danh sách yêu thích." - /> + {isOpen && setIsOpen(!isOpen)} />}
) } diff --git a/client/src/pages/Movies/Movies.jsx b/client/src/pages/Movies/Movies.jsx index cefd0a2..5be2e49 100644 --- a/client/src/pages/Movies/Movies.jsx +++ b/client/src/pages/Movies/Movies.jsx @@ -7,8 +7,11 @@ import { AdvancedFilter } from '../../components/common/Filters/AdvancedFilter' import { Pagination } from '../../components/common/Pagination/Pagination' import { MovieListSkeletonGrid } from '../../components/common/Skeletons/MovieCardSkeleton' import MediaCard_2 from '../../components/common/Movies/MediaCollection/MediaGrid/MediaCard.jsx/MediaCard_2.jsx' +import RequireLoginModal from '../../components/common/Modals/RequireLoginModal.jsx' +import { useHome } from '../../contexts/HomeContext.jsx' const Movies = () => { + const { isLoginModalOpen, setIsLoginModalOpen } = useHome() // 1. Quản lý State qua URL const { filters, updateFilters, resetFilters, setPage } = useMediaFilters() @@ -92,6 +95,11 @@ const Movies = () => {
)}
+ setIsLoginModalOpen(false)} + message="Bạn cần đăng nhập để lưu phim vào danh sách yêu thích." + />
) } diff --git a/client/src/pages/Profile/ProfileForm.jsx b/client/src/pages/Profile/ProfileForm.jsx deleted file mode 100644 index f949cce..0000000 --- a/client/src/pages/Profile/ProfileForm.jsx +++ /dev/null @@ -1,219 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useParams, useNavigate, Link } from 'react-router-dom'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { ArrowLeft, Trash2 } from 'lucide-react'; -import { getProfileApi, createProfileApi, updateProfileApi, deleteProfileApi } from '../../api/profileApi'; -import Footer from '../../components/layouts/Footer'; - -/** - * Render a form for creating, editing, and deleting a user profile. - * - * When an `id` route param is present the component loads the existing profile - * and pre-fills the form fields. The form lets the user edit the profile name - * and toggle a kid/adult type, save changes (create or update), and delete the - * profile after confirming in a modal. On successful create/update/delete the - * component invalidates the cached `userProfiles` query and navigates to - * `/profiles`. Load and save errors are displayed in the UI. - * - * @returns {JSX.Element} The profile form UI. - */ -export default function ProfileForm() { - const { id } = useParams(); - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const isEditMode = Boolean(id); - - const { data: profileData, isError: isLoadError, error: loadError } = useQuery({ - queryKey: ['userProfile', id], - queryFn: () => getProfileApi(id), - enabled: isEditMode, - }); - - const [profileName, setProfileName] = useState(''); - const [profileType, setProfileType] = useState('ADULT'); - const [showDeleteModal, setShowDeleteModal] = useState(false); - const [saveError, setSaveError] = useState(''); - - useEffect(() => { - if (profileData) { - setProfileName(profileData.name); - setProfileType(profileData.type); - } - }, [profileData]); - - const { mutate: saveProfile, isPending: isSaving } = useMutation({ - mutationFn: isEditMode - ? (data) => updateProfileApi({ id, ...data }) - : createProfileApi, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['userProfiles'] }); - navigate('/profiles'); - }, - onError: (err) => { - setSaveError(err?.response?.data?.message || 'Lưu hồ sơ thất bại'); - }, - }); - - const { mutate: deleteProfile, isPending: isDeleting } = useMutation({ - mutationFn: deleteProfileApi, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['userProfiles'] }); - setShowDeleteModal(false); - navigate('/profiles'); - }, - onError: (err) => { - setSaveError(err?.response?.data?.message || 'Xóa hồ sơ thất bại'); - }, - }); - - const handleSave = (e) => { - e.preventDefault(); - setSaveError(''); - if (!profileName.trim()) return; - saveProfile({ name: profileName.trim(), type: profileType }); - }; - - if (isEditMode && isLoadError) { - return ( -
- {/* Background glow effects */} -
-
-
-
-

Không thể tải thông tin hồ sơ

-

- Cần chạy backend server: mở terminal riêng,{' '} - cd server rồi{' '} - npm run dev -

-

{loadError?.message}

- - Quay lại - -
-
-
- ); - } - - return ( -
- {/* Background glow effects */} -
-
-
-
- - - -
- -
-
-
-
- - {profileName ? profileName.charAt(0).toUpperCase() : '?'} - -
-
- -
- -
-
-
- - {isEditMode && ( - - )} -
- setProfileName(e.target.value)} - className="w-full bg-[#1a1a1a] border border-neutral-700 text-white px-4 py-3 rounded-md text-lg outline-none focus:border-gray-500 transition" - placeholder="Nhập tên hồ sơ" - /> -
- -
- Hồ sơ trẻ em : - -
- - {saveError && ( -

{saveError}

- )} - -
- - - Hủy - -
-
-
- - {showDeleteModal && ( -
-
-

Xóa hồ sơ này?

-

- Hồ sơ này cùng lịch sử và cài đặt của nó sẽ bị xóa vĩnh viễn, không khôi phục -

-
- - -
-
-
- )} -
-
-
-
- ); -} diff --git a/client/src/pages/Profile/ProfileManage.jsx b/client/src/pages/Profile/ProfileManage.jsx deleted file mode 100644 index a47f155..0000000 --- a/client/src/pages/Profile/ProfileManage.jsx +++ /dev/null @@ -1,191 +0,0 @@ -import { useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Pencil, Trash2, ArrowLeft, User } from 'lucide-react'; -import { getProfilesApi, deleteProfileApi } from '../../api/profileApi'; -import Footer from '../../components/layouts/Footer'; - -/** - * Render the profile management interface for viewing, editing, and deleting user profiles. - * - * Shows a loading screen while profiles load, an error screen if fetching fails, an empty-state with a link to add a profile when none exist, and a list of profiles with edit and delete actions. Deleting a profile opens a confirmation modal; deletion errors are displayed as an alert. - * @returns {JSX.Element} The profile management page UI. - */ -export default function ProfileManage() { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const [deleteTarget, setDeleteTarget] = useState(null); - const [errorMessage, setErrorMessage] = useState(''); - - const { data: profiles = [], isLoading, isError, error } = useQuery({ - queryKey: ['userProfiles'], - queryFn: getProfilesApi, - }); - - const { mutate: deleteProfile, isPending: isDeleting } = useMutation({ - mutationFn: deleteProfileApi, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['userProfiles'] }); - setDeleteTarget(null); - setErrorMessage(''); - }, - onError: (err) => { - setErrorMessage(err?.response?.data?.message || 'Xóa hồ sơ thất bại'); - }, - }); - - if (isLoading) { - return ( -
- {/* Background glow effects */} -
-
-
-
-

Quản lý hồ sơ

-
Đang tải...
-
-
-
- ); - } - - if (isError) { - return ( -
- {/* Background glow effects */} -
-
-
-
-
- - - -

Quản lý hồ sơ

-
-
-

Không thể tải danh sách hồ sơ

-

- Cần chạy backend server: mở terminal riêng,{' '} - cd server rồi{' '} - npm run dev -

-

{error?.message}

-
-
-
-
- ); - } - - return ( -
- {/* Background glow effects */} -
-
-
-
-
- - - -

Quản lý hồ sơ

-
- - {errorMessage && ( -
- {errorMessage} -
- )} - - {profiles.length === 0 ? ( -
-

Chưa có hồ sơ nào.

- - Thêm hồ sơ - -
- ) : ( -
- {profiles.map((profile) => ( -
-
-
- -
- - {profile.name} - {profile.type === 'KID' && ( - Trẻ em - )} - -
-
- - -
-
- ))} -
- )} - -
- - Xong - -
-
- - {deleteTarget && ( -
-
-

Xóa hồ sơ này?

-

- Hồ sơ “{deleteTarget.name}” -

-

- sẽ bị xóa vĩnh viễn cùng lịch sử và cài đặt, không thể khôi phục -

-
- - -
-
-
- )} -
-
-
- ); -} diff --git a/client/src/pages/Profile/ProfileSelection.jsx b/client/src/pages/Profile/ProfileSelection.jsx deleted file mode 100644 index 0915fd8..0000000 --- a/client/src/pages/Profile/ProfileSelection.jsx +++ /dev/null @@ -1,114 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { Link } from 'react-router-dom'; -import { Plus, User } from 'lucide-react'; -import { getProfilesApi } from '../../api/profileApi'; -import Footer from '../../components/layouts/Footer'; - -/** - * Render the profile selection page and its loading/error states. - * - * Fetches the current user's profiles and displays a selection UI: a loading screen while fetching, - * an error screen when the query fails (showing the error message), an empty-state message when - * there are no profiles, or a grid of profile tiles (with a "Trẻ em" badge for kid profiles) plus - * controls to add or manage profiles. - * @returns {JSX.Element} The profile selection React element. - */ -export default function ProfileSelection() { - const { data: profiles = [], isLoading, isError, error } = useQuery({ - queryKey: ['userProfiles'], - queryFn: getProfilesApi, - }); - - if (isLoading) { - return ( -
- {/* Background glow effects */} -
-
-
-
-

Ai đang xem?

-
Đang tải...
-
-
-
- ); - } - - if (isError) { - return ( -
- {/* Background glow effects */} -
-
-
-
-

Ai đang xem?

-
-

Không thể tải danh sách hồ sơ

-

- Cần chạy backend server: mở terminal riêng,{' '} - cd server rồi{' '} - npm run dev -

-

{error?.message}

-
-
-
-
- ); - } - - return ( -
- {/* Background glow effects */} -
-
-
-
-

Ai đang xem?

- - {profiles.length === 0 ? ( -
-

Chưa có hồ sơ nào.

-

Thêm hồ sơ mới để bắt đầu.

-
- ) : ( -
- {profiles.map((profile) => ( -
-
- -
- - {profile.name} - {profile.type === 'KID' && ( - Trẻ em - )} - -
- ))} - - -
- -
- - Thêm hồ sơ - - -
- )} - - - Quản lý hồ sơ - -
-
-
-
- ); -} diff --git a/client/src/pages/Profile/index.js b/client/src/pages/Profile/index.js deleted file mode 100644 index de07513..0000000 --- a/client/src/pages/Profile/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export { default as ProfileSelection } from './ProfileSelection'; -export { default as ProfileManage } from './ProfileManage'; -export { default as ProfileForm } from './ProfileForm'; diff --git a/client/src/providers/AuthProvider.jsx b/client/src/providers/AuthProvider.jsx index 152daae..6ef6d6a 100644 --- a/client/src/providers/AuthProvider.jsx +++ b/client/src/providers/AuthProvider.jsx @@ -8,6 +8,7 @@ export const AuthProvider = ({ children }) => { const [user, setUser] = useState(null) const [isAuthenticated, setIsAuthenticated] = useState(false) const [isLoading, setIsLoading] = useState(true) + const [isLoginModalOpen, setIsLoginModalOpen] = useState(false) // Kiểm tra session khi app khởi động useEffect(() => { @@ -50,7 +51,18 @@ export const AuthProvider = ({ children }) => { } } - const value = { user, isAuthenticated, isLoading, login, logout } + // ĐỒNG NHẤT TOÀN BỘ VÀO MỘT OBJECT DUY NHẤT + const contextValue = { + user, + isAuthenticated, + isLoading, + login, + logout, + isLoginModalOpen, + setIsLoginModalOpen, + } - return {children} + return ( + {children} + ) } diff --git a/client/src/providers/HomeProvider.jsx b/client/src/providers/HomeProvider.jsx index 4c9393d..08136a0 100644 --- a/client/src/providers/HomeProvider.jsx +++ b/client/src/providers/HomeProvider.jsx @@ -6,16 +6,19 @@ export const HomeProvider = ({ children }) => { const [loved, setLoved] = useState(false) const [mediasWatching, setMediasWatching] = useState([]) const [selectedMediaId, setSelectedMediaId] = useState(null) - const [isLoginModalOpen, setIsLoginModalOpen] = useState(false) + const { + mediasCollection, + bannerTrailers, + bannerLogos, + isLoading, + isError, + error, + } = useMedias() - const { mediasCollection, bannerTrailers, bannerLogos, isLoading, isError, error } = useMedias() - - const activeMediaId = selectedMediaId || mediasCollection?.mediaBanner?.[0]?.id - return ( { isLoading, isError, error, - isLoginModalOpen, - setIsLoginModalOpen, }} > {children} diff --git a/server/src/controllers/user.controller.js b/server/src/controllers/user.controller.js new file mode 100644 index 0000000..72c62ab --- /dev/null +++ b/server/src/controllers/user.controller.js @@ -0,0 +1,151 @@ + +import { StatusCodes } from 'http-status-codes' +import bcrypt from 'bcryptjs' +import { AppError } from '../utils/appError.js' +import { catchAsync } from '../utils/catchAsync.js' +import prisma from '../config/database.config.js' + + +export const getUserProfile = catchAsync(async (req, res) => { + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { + id: true, + email: true, + fullName: true, + phone: true, + dateOfBirth: true, + gender: true, + avatarUrl: true, + role: true, + createdAt: true + } + }) + + if (!user) { + throw new AppError('Người dùng không tồn tại', StatusCodes.NOT_FOUND) + } + + res.status(StatusCodes.OK).json({ + success: true, + data: user + }) +}) + +export const updateUserProfile = catchAsync(async (req, res) => { + const { fullName, phone, dateOfBirth, gender } = req.body + const updateData = {} + + if (fullName !== undefined) updateData.fullName = fullName + if (phone !== undefined) updateData.phone = phone + if (dateOfBirth !== undefined) updateData.dateOfBirth = new Date(dateOfBirth) + if (gender !== undefined) updateData.gender = gender + + // Handle avatar upload if file exists + if (req.file) { + updateData.avatarUrl = `/uploads/${req.file.filename}` + + } + + const updatedUser = await prisma.user.update({ + where: { id: req.user.id }, + data: updateData, + select: { + id: true, + email: true, + fullName: true, + phone: true, + dateOfBirth: true, + gender: true, + avatarUrl: true + } + }) + + res.status(StatusCodes.OK).json({ + success: true, + message: 'Cập nhật thông tin thành công', + data: updatedUser + }) +}) + +export const changePassword = catchAsync(async (req, res) => { + const { currentPassword, newPassword } = req.body + + if (!currentPassword || !newPassword) { + throw new AppError('Vui lòng cung cấp mật khẩu hiện tại và mật khẩu mới', StatusCodes.BAD_REQUEST) + } + + if (newPassword.length < 6) { + throw new AppError('Mật khẩu mới phải có ít nhất 6 ký tự', StatusCodes.BAD_REQUEST) + } + + const user = await prisma.user.findUnique({ + where: { id: req.user.id } + }) + + if (!user || !user.password) { + throw new AppError('Tài khoản không hợp lệ hoặc đăng nhập bằng phương thức khác', StatusCodes.BAD_REQUEST) + } + + const isPasswordCorrect = await bcrypt.compare(currentPassword, user.password) + if (!isPasswordCorrect) { + throw new AppError('Mật khẩu hiện tại không chính xác', StatusCodes.UNAUTHORIZED) + } + + const isSameAsCurrent = await bcrypt.compare(newPassword, user.password) + if (isSameAsCurrent) { + throw new AppError('Mật khẩu mới phải khác mật khẩu hiện tại', StatusCodes.BAD_REQUEST) + } + + const hashedNewPassword = await bcrypt.hash(newPassword, 12) + + await prisma.user.update({ + where: { id: req.user.id }, + data: { password: hashedNewPassword } + }) + + res.status(StatusCodes.OK).json({ + success: true, + message: 'Đổi mật khẩu thành công' + }) +}) + +export const getSubscriptionHistory = catchAsync(async (req, res) => { + // Get active subscription and payment history + const activeSubscription = await prisma.subscription.findFirst({ + where: { + userId: req.user.id, + status: 'ACTIVE', + endAt: { gt: new Date() } + }, + include: { plan: true }, + orderBy: { createdAt: 'desc' } + }) + + const payments = await prisma.payment.findMany({ + where: { userId: req.user.id }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + providerTransactionId: true, + amount: true, + currency: true, + status: true, + paidAt: true, + createdAt: true + } + }) + + res.status(StatusCodes.OK).json({ + success: true, + data: { + currentPlan: activeSubscription ? { + code: activeSubscription.plan.code, + name: activeSubscription.plan.name, + price: activeSubscription.plan.price, + endAt: activeSubscription.endAt + } : null, + history: payments + } + }) +}) diff --git a/server/src/routes/index.js b/server/src/routes/index.js index 3c5e458..acc0446 100644 --- a/server/src/routes/index.js +++ b/server/src/routes/index.js @@ -9,11 +9,12 @@ import commentRoutes from './comment.route.js' import devRoutes from './dev.route.js' import profileRoutes from './profile.route.js' import collectionsRoutes from './collection.route.js' - +import userRoutes from './user.route.js' const apiRouter = express.Router() apiRouter.use('/auth', authRoutes) +apiRouter.use('/users', userRoutes) apiRouter.use('/stats', statsRoutes) apiRouter.use('/medias', mediasRoutes) apiRouter.use('/premium', premiumRoutes) diff --git a/server/src/routes/user.route.js b/server/src/routes/user.route.js new file mode 100644 index 0000000..f1daa90 --- /dev/null +++ b/server/src/routes/user.route.js @@ -0,0 +1,23 @@ +import express from 'express' +import { + getUserProfile, + updateUserProfile, + changePassword, + getSubscriptionHistory +} from '../controllers/user.controller.js' +import { verifyUserSession } from '../middlewares/userAuth.middleware.js' +import { uploadAvatar } from '../middlewares/upload.middleware.js' // Assuming upload middleware exists + +const Router = express.Router() + +// All routes require user authentication +Router.use(verifyUserSession) + +Router.get('/profile', getUserProfile) +// Using multer for avatar upload, assuming 'avatar' is the field name +Router.put('/profile', uploadAvatar.single('avatar'), updateUserProfile) + +Router.post('/change-password', changePassword) +Router.get('/subscription-history', getSubscriptionHistory) + +export default Router