diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e9715b..fc125be 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { Agentation } from 'agentation' @@ -6,22 +6,65 @@ import { ThemeProvider } from '@/app/providers/theme-provider' import AutoScrollToTop from '@/components/scroll/auto-scroll-to-top' import { scheduleTokenRefresh } from '@/core/shared/auth-refresh' import { getAccessTokenFromLS } from '@/core/shared/storage' +import { useAuthStore } from '@/core/store/features/auth/authStore' import useRoutesElements from '@/hooks/routes/use-router-element' import '@/styles/theme.css' +import { useUserInfo } from './hooks/tanstack-query/auth/use-query-auth' +import { type UserRole } from './models/user/types' -const App = () => { +const AppContent = () => { const router = useRoutesElements() + return <>{router} +} + +const App = () => { + const [isAppLoading, setIsAppLoading] = useState(true) + const logout = useAuthStore((state) => state.logout) + const updateUser = useAuthStore((state) => state.updateUser) + const { data: userData } = useUserInfo() - // F5/mở lại tab mà còn token → khởi động timer proactive refresh theo exp. useEffect(() => { - if (getAccessTokenFromLS()) scheduleTokenRefresh() - }, []) + const initializeApp = async () => { + const accessToken = getAccessTokenFromLS() + if (accessToken) { + try { + updateUser({ + userId: userData?.id || '', + fullName: userData?.fullName || '', + email: userData?.email || '', + phone: userData?.phone || '', + location: userData?.location || '', + avatarUrl: userData?.avatarUrl || '', + roleName: userData?.roleName as UserRole + }) + scheduleTokenRefresh() + } catch (error: any) { + console.error('Failed to fetch user info on app initialize:', error) + if (error.response && [401, 403].includes(error.response.status)) { + logout() + } + } + } + setIsAppLoading(false) + } + + initializeApp() + }, [logout, updateUser, userData?.avatarUrl, userData?.email, userData?.fullName, userData?.id, userData?.location, userData?.phone, userData?.roleName]) + + if (isAppLoading) { + return ( +
+
+

Đang khởi tạo ứng dụng...

+
+ ) + } return ( - {router} + {import.meta.env.DEV && } ) diff --git a/frontend/src/_mocks/lawyer.mock.ts b/frontend/src/_mocks/lawyer.mock.ts index 99e5081..bdcdcb4 100644 --- a/frontend/src/_mocks/lawyer.mock.ts +++ b/frontend/src/_mocks/lawyer.mock.ts @@ -12,7 +12,7 @@ export const MOCK_LAWYERS: Lawyer[] = [ averageRating: 4.9, successfulCases: 120, specializations: ['Doanh nghiệp & Thương mại', 'Lao động'], - city: 'Đà Nẵng' + location: 'Đà Nẵng' }, { id: 'lyr-2', @@ -23,7 +23,7 @@ export const MOCK_LAWYERS: Lawyer[] = [ averageRating: 4.8, successfulCases: 95, specializations: ['Dân sự & Thừa kế'], - city: 'Hà Nội' + location: 'Hà Nội' }, { id: 'lyr-3', @@ -34,7 +34,7 @@ export const MOCK_LAWYERS: Lawyer[] = [ averageRating: 5.0, successfulCases: 210, specializations: ['Hình sự', 'Hành chính'], - city: 'TP. Hồ Chí Minh' + location: 'TP. Hồ Chí Minh' }, { id: 'lyr-4', @@ -45,7 +45,7 @@ export const MOCK_LAWYERS: Lawyer[] = [ averageRating: 4.7, successfulCases: 65, specializations: ['Sở hữu trí tuệ', 'Đất đai & Bất động sản'], - city: 'Cần Thơ' + location: 'Cần Thơ' }, { id: 'lyr-5', @@ -56,7 +56,7 @@ export const MOCK_LAWYERS: Lawyer[] = [ averageRating: 4.9, successfulCases: 85, specializations: ['Doanh nghiệp & Thương mại'], - city: 'Đà Nẵng' + location: 'Đà Nẵng' }, { id: 'lyr-6', @@ -67,7 +67,7 @@ export const MOCK_LAWYERS: Lawyer[] = [ averageRating: 4.6, successfulCases: 42, specializations: ['Hôn nhân & Gia đình', 'Dân sự & Thừa kế'], - city: 'Hải Phòng' + location: 'Hải Phòng' }, { id: 'lyr-7', @@ -78,7 +78,7 @@ export const MOCK_LAWYERS: Lawyer[] = [ averageRating: 4.8, successfulCases: 67, specializations: ['Lao động', 'Hành chính'], - city: 'Quảng Nam' + location: 'Quảng Nam' }, { id: 'lyr-8', @@ -89,7 +89,7 @@ export const MOCK_LAWYERS: Lawyer[] = [ averageRating: 4.9, successfulCases: 180, specializations: ['Đất đai & Bất động sản'], - city: 'Đà Nẵng' + location: 'Đà Nẵng' }, { id: 'lyr-9', @@ -100,7 +100,7 @@ export const MOCK_LAWYERS: Lawyer[] = [ averageRating: 4.8, successfulCases: 140, specializations: ['Đất đai & Bất động sản', 'Dân sự & Thừa kế'], - city: 'Hà Nội' + location: 'Hà Nội' }, { id: 'lyr-10', @@ -111,7 +111,7 @@ export const MOCK_LAWYERS: Lawyer[] = [ averageRating: 4.9, successfulCases: 165, specializations: ['Doanh nghiệp & Thương mại'], - city: 'TP. Hồ Chí Minh' + location: 'TP. Hồ Chí Minh' }, { id: 'lyr-11', @@ -122,7 +122,7 @@ export const MOCK_LAWYERS: Lawyer[] = [ averageRating: 4.6, successfulCases: 50, specializations: ['Sở hữu trí tuệ'], - city: 'Đà Nẵng' + location: 'Đà Nẵng' }, { id: 'lyr-12', @@ -133,7 +133,7 @@ export const MOCK_LAWYERS: Lawyer[] = [ averageRating: 4.8, successfulCases: 135, specializations: ['Hôn nhân & Gia đình'], - city: 'TP. Hồ Chí Minh' + location: 'TP. Hồ Chí Minh' } ] @@ -152,7 +152,7 @@ export const getLawyerListMock = ( size: number = 8, filters?: { category?: string - city?: string + location?: string searchQuery?: string sortBy?: 'default' | 'rating' | 'cases' } @@ -164,7 +164,7 @@ export const getLawyerListMock = ( filtered = filtered.filter( (l) => l.fullName.toLowerCase().includes(query) || - l.bio.toLowerCase().includes(query) || + (l.bio || '').toLowerCase().includes(query) || l.specializations.some((s) => s.toLowerCase().includes(query)) ) } @@ -174,8 +174,8 @@ export const getLawyerListMock = ( filtered = filtered.filter((l) => l.specializations.includes(category)) } - if (filters?.city && filters.city !== 'Tất cả') { - filtered = filtered.filter((l) => l.city === filters.city) + if (filters?.location && filters.location !== 'Tất cả') { + filtered = filtered.filter((l) => l.location === filters.location) } if (filters?.sortBy === 'rating') { @@ -205,27 +205,36 @@ export const getLawyerListMock = ( export const getLawyerDetailMock = (id: string): LawyerDetailResponse => { const lawyer = MOCK_LAWYERS.find((l) => l.id === id) || MOCK_LAWYERS[0] - const expMatch = lawyer.careerHistory.match(/\d+/) + const expMatch = (lawyer.careerHistory || '').match(/\d+/) const experienceYears = expMatch ? parseInt(expMatch[0], 10) : 5 return { data: { items: [], summary: { + id: lawyer.id, + email: 'lawyer@legalai.vn', + phone: '0987654321', + name: lawyer.fullName, + roleName: 'LAWYER', + bio: lawyer.bio, averageRating: lawyer.averageRating, careerHistory: lawyer.careerHistory, careerMilestones: [], consultingFee: 500000, experienceYears, + successfulCases: lawyer.successfulCases || 10, + lawyerStatus: 'AVAILABLE', + status: 'ACTIVE', licenseInfo: { isVerified: true, licenseFileUrl: null, licenseIssuer: 'Bộ Tư pháp', licenseNumber: `LS-${lawyer.id.replace('lyr-', '1000')}` }, - name: lawyer.fullName, - role: 'Luật sư thành viên', - specializations: lawyer.specializations + specializations: lawyer.specializations, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() } } } diff --git a/frontend/src/app/layout/layout-main.tsx b/frontend/src/app/layout/layout-main.tsx index 1f24555..718877d 100644 --- a/frontend/src/app/layout/layout-main.tsx +++ b/frontend/src/app/layout/layout-main.tsx @@ -2,6 +2,7 @@ import { type ReactNode, Suspense } from 'react' import { Outlet } from 'react-router-dom' +import BottomNav from '@/components/bottom-nav/bottom-nav' import SideBar from '@/components/side-bar/side-bar' import TopBar from '@/components/top-bar/top-bar' import LoadingSpinner from '@/components/ui/loading-spinner' @@ -16,7 +17,7 @@ const LayoutMain = ({ children }: LayoutMainProps) => {
-
+
{
{
+
diff --git a/frontend/src/app/providers/query-provider.tsx b/frontend/src/app/providers/query-provider.tsx index eb1b487..a0bce78 100644 --- a/frontend/src/app/providers/query-provider.tsx +++ b/frontend/src/app/providers/query-provider.tsx @@ -7,7 +7,8 @@ interface QueryProviderProps { children: ReactNode } -const queryClient = new QueryClient({ +// eslint-disable-next-line react-refresh/only-export-components +export const queryClient = new QueryClient({ defaultOptions: { queries: { refetchIntervalInBackground: false, @@ -20,6 +21,15 @@ const queryClient = new QueryClient({ } }) +// Set specific staleTime defaults (5 minutes for userInfo, 3 minutes for lawyers, templates, and laws) +queryClient.setQueryDefaults(['userInfo'], { staleTime: 5 * 60 * 1000 }) +queryClient.setQueryDefaults(['lawyers'], { staleTime: 3 * 60 * 1000 }) +queryClient.setQueryDefaults(['lawyerDetail'], { staleTime: 3 * 60 * 1000 }) +queryClient.setQueryDefaults(['templates'], { staleTime: 3 * 60 * 1000 }) +queryClient.setQueryDefaults(['templateDetail'], { staleTime: 3 * 60 * 1000 }) +queryClient.setQueryDefaults(['laws'], { staleTime: 3 * 60 * 1000 }) +queryClient.setQueryDefaults(['lawDetail'], { staleTime: 3 * 60 * 1000 }) + export default function QueryProvider({ children }: QueryProviderProps) { return ( diff --git a/frontend/src/components/bottom-nav/bottom-nav.tsx b/frontend/src/components/bottom-nav/bottom-nav.tsx new file mode 100644 index 0000000..f1120d8 --- /dev/null +++ b/frontend/src/components/bottom-nav/bottom-nav.tsx @@ -0,0 +1,99 @@ +import { Home, MessageCircle, MessageSquare, Scale, User } from 'lucide-react' +import { Link, useLocation } from 'react-router-dom' + +import { ROUTE } from '@/core/constants/path' +import { cn } from '@/core/lib/utils' + +export default function BottomNav() { + const location = useLocation() + + const isActive = (path: string) => { + if (path === ROUTE.USER.ROOT) { + return location.pathname === ROUTE.USER.ROOT + } + return location.pathname === path || location.pathname.startsWith(`${path}/`) + } + + const items = [ + { + icon: Home, + path: ROUTE.USER.ROOT, + label: 'Trang chủ' + }, + { + icon: MessageCircle, + path: ROUTE.USER.MESSAGES, + label: 'Tin nhắn' + }, + { + icon: MessageSquare, + path: ROUTE.USER.CHAT_AI, + label: 'Phân tích pháp luật', + isCenter: true + }, + { + icon: Scale, + path: ROUTE.USER.LEGAL, + label: 'Văn bản pháp luật' + }, + { + icon: User, + path: ROUTE.PROFILE.ROOT, + label: 'Trang cá nhân' + } + ] + + return ( +
+
+ {items.map((item, idx) => { + const Icon = item.icon + const active = isActive(item.path) + + const handleClick = (e: React.MouseEvent) => { + if (active && item.path === ROUTE.USER.ROOT) { + e.preventDefault() + window.scrollTo({ top: 0, behavior: 'smooth' }) + setTimeout(() => { + window.location.reload() + }, 150) + } + } + + if (item.isCenter) { + return ( + + {/* Glow Ring Effect */} + + + + ) + } + + return ( + + + + ) + })} +
+
+ ) +} diff --git a/frontend/src/components/side-bar/components/user-side-bar.tsx b/frontend/src/components/side-bar/components/user-side-bar.tsx index 1ea6bf4..f99d050 100644 --- a/frontend/src/components/side-bar/components/user-side-bar.tsx +++ b/frontend/src/components/side-bar/components/user-side-bar.tsx @@ -45,7 +45,7 @@ export default function UserSideBar() { return (