diff --git a/apps/web/src/app/dashboard/host-dashboard/page.tsx b/apps/web/src/app/dashboard/host-dashboard/page.tsx index c6319c8..b182583 100644 --- a/apps/web/src/app/dashboard/host-dashboard/page.tsx +++ b/apps/web/src/app/dashboard/host-dashboard/page.tsx @@ -4,8 +4,7 @@ import BookingHistory from '@/components/dashboard/BookingHistory'; import NotificationSystem from '@/components/dashboard/NotificationSystem'; import ProfileManagement from '@/components/dashboard/ProfileManagement'; import PropertyManagement from '@/components/dashboard/PropertyManagement'; -import { Breadcrumb } from '@/components/ui/breadcrumb'; -import RoleGuard from '@/hooks/auth/RoleGuard'; +import { RoleGuard } from '@/components/guards/RoleGuard'; import { useRealTimeNotifications } from '@/hooks/useRealTimeUpdates'; import { Calendar, DollarSign, Settings, User, Wallet } from 'lucide-react'; import Image from 'next/image'; diff --git a/apps/web/src/app/dashboard/tenant-dashboard/page.tsx b/apps/web/src/app/dashboard/tenant-dashboard/page.tsx index d154d80..7717a5f 100644 --- a/apps/web/src/app/dashboard/tenant-dashboard/page.tsx +++ b/apps/web/src/app/dashboard/tenant-dashboard/page.tsx @@ -3,8 +3,7 @@ import BookingHistory from '@/components/dashboard/BookingHistory'; import NotificationSystem from '@/components/dashboard/NotificationSystem'; import ProfileManagement from '@/components/dashboard/ProfileManagement'; -import { Breadcrumb } from '@/components/ui/breadcrumb'; -import RoleGuard from '@/hooks/auth/RoleGuard'; +import { RoleGuard } from '@/components/guards/RoleGuard'; import { BarChart3, Calendar, diff --git a/apps/web/src/app/invitations/page.tsx b/apps/web/src/app/invitations/page.tsx index 8f7e5eb..8a67226 100644 --- a/apps/web/src/app/invitations/page.tsx +++ b/apps/web/src/app/invitations/page.tsx @@ -1,82 +1,521 @@ 'use client'; import { RightSidebar } from '@/components/layout/RightSidebar'; -import ProtectedRoute from '@/hooks/auth/procted-route'; -import { Search } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { IconContainer } from '@/components/ui/icon-container'; +import { + DUAL_MENU_ITEMS, + GUEST_MENU_ITEMS, + HOST_MENU_ITEMS, + type MenuItem, + TENANT_MENU_ITEMS, +} from '@/constants/menu-items'; +import { useAuth } from '@/hooks/auth/use-auth'; +import { useUserRole } from '@/hooks/useUserRole'; +import { ChevronDown, Menu, Search, X } from 'lucide-react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useMemo, useState } from 'react'; + +type Invitation = { + id: string; + property: string; + owner: string; + checkIn: string; + checkOut: string; + created: string; + status: string; + invitation: string; + paymentMethod?: string; +}; + +type SortOrder = 'newest' | 'oldest'; +type PropertySort = 'a-z' | 'z-a'; +type StatusFilter = 'all' | 'pending' | 'accepted' | 'declined' | 'expired'; + +const MOBILE_ROWS_PER_PAGE = 5; +const DESKTOP_ROWS_PER_PAGE = 10; const InvitationsPage = () => { + const { isAuthenticated } = useAuth(); + const { role } = useUserRole(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [mobilePage, setMobilePage] = useState(1); + const [desktopPage, setDesktopPage] = useState(1); + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [createdSort, setCreatedSort] = useState('newest'); + const [propertySort, setPropertySort] = useState('a-z'); + + const menuItems: MenuItem[] = useMemo(() => { + if (!isAuthenticated) return GUEST_MENU_ITEMS; + switch (role) { + case 'host': + return HOST_MENU_ITEMS; + case 'dual': + return DUAL_MENU_ITEMS; + default: + return TENANT_MENU_ITEMS; + } + }, [role, isAuthenticated]); + + const drawerItems = useMemo(() => menuItems.filter((item) => item.id !== 'menu'), [menuItems]); + + const invitations = useMemo(() => [], []); + + const filteredInvitations = useMemo(() => { + const normalizedQuery = searchQuery.trim().toLowerCase(); + let filtered = invitations.filter((invite) => { + if (statusFilter !== 'all' && invite.status.toLowerCase() !== statusFilter) { + return false; + } + if (!normalizedQuery) return true; + return ( + invite.property.toLowerCase().includes(normalizedQuery) || + invite.owner.toLowerCase().includes(normalizedQuery) || + invite.status.toLowerCase().includes(normalizedQuery) || + invite.invitation.toLowerCase().includes(normalizedQuery) + ); + }); + + filtered = [...filtered].sort((left, right) => { + const propertyCompare = left.property.localeCompare(right.property); + if (propertyCompare !== 0) { + return propertySort === 'a-z' ? propertyCompare : -propertyCompare; + } + + const leftDate = new Date(left.created).getTime(); + const rightDate = new Date(right.created).getTime(); + return createdSort === 'newest' ? rightDate - leftDate : leftDate - rightDate; + }); + + return filtered; + }, [createdSort, invitations, propertySort, searchQuery, statusFilter]); + + const mobileTotalPages = Math.max( + 1, + Math.ceil(filteredInvitations.length / MOBILE_ROWS_PER_PAGE) + ); + const desktopTotalPages = Math.max( + 1, + Math.ceil(filteredInvitations.length / DESKTOP_ROWS_PER_PAGE) + ); + const currentMobileInvitations = filteredInvitations.slice( + (mobilePage - 1) * MOBILE_ROWS_PER_PAGE, + mobilePage * MOBILE_ROWS_PER_PAGE + ); + const currentDesktopInvitations = filteredInvitations.slice( + (desktopPage - 1) * DESKTOP_ROWS_PER_PAGE, + desktopPage * DESKTOP_ROWS_PER_PAGE + ); + + const handleSearchChange = (value: string) => { + setSearchQuery(value); + setMobilePage(1); + setDesktopPage(1); + }; + + const handleStatusChange = (value: StatusFilter) => { + setStatusFilter(value); + setMobilePage(1); + setDesktopPage(1); + }; + + const handleCreatedSortChange = (value: SortOrder) => { + setCreatedSort(value); + setMobilePage(1); + setDesktopPage(1); + }; + + const handlePropertySortChange = (value: PropertySort) => { + setPropertySort(value); + setMobilePage(1); + setDesktopPage(1); + }; + return ( - -
-
-
-

My invitations

-
- -
-
-
-
- - - - -
+
+
+
+ +

My invitations

+
-
-
Rows per page
-
10
-
-
+
+
+
+ -
-
-
Property
-
Owner
-
Check-in
-
Check-out
-
Created
-
Status
-
Invitation
+
+
+ Total: {filteredInvitations.length} invitations
- -
- No invitations sent yet +
+ Total: {filteredInvitations.length} invitations
-
-
-
Total: 0 invitations
-
- + handleStatusChange(value as StatusFilter)} + > + All + Pending + Accepted + Declined + Expired + + + + + handleCreatedSortChange(value as SortOrder)} + > + Newest + Oldest + + + + + handlePropertySortChange(value as PropertySort)} + > + A-Z + Z-A + + + + +
+ Rows per page + + +
+ + + + + +
+ setMobilePage((prev) => Math.max(1, prev - 1))} + onNext={() => setMobilePage((prev) => Math.min(mobileTotalPages, prev + 1))} + /> + setDesktopPage((prev) => Math.max(1, prev - 1))} + onNext={() => setDesktopPage((prev) => Math.min(desktopTotalPages, prev + 1))} + />
+
- + + setIsMenuOpen(false)} + menuItems={drawerItems} + /> +
); }; +const SearchBar = ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( +
+ + + + onChange(event.target.value)} + /> +
+); + +const FilterDropdown = ({ + label, + valueLabel, + children, +}: { + label: string; + valueLabel: string; + children: React.ReactNode; +}) => ( + + + + + + {children} + + +); + +const DesktopInvitationsTable = ({ invitations }: { invitations: Invitation[] }) => ( +
+
+
Property
+
Owner
+
Check-in
+
Check-out
+
+ Created + +
+
Status
+
Invitation
+
+ + {invitations.length === 0 ? ( +
+ No invitations sent yet +
+ ) : ( +
+ {invitations.map((invite) => ( +
+
{invite.property}
+
{invite.owner}
+
{invite.checkIn}
+
{invite.checkOut}
+
{invite.created}
+
{invite.status}
+
{invite.invitation}
+
+ ))} +
+ )} +
+); + +const MobileInvitationsList = ({ invitations }: { invitations: Invitation[] }) => ( +
+ {invitations.length === 0 ? ( +
+ No invitations sent yet +
+ ) : ( + invitations.map((invite) => ( +
+
+
+
{invite.property}
+
{invite.owner}
+
+ + {invite.status} + +
+ +
+
+
Check-in
+
{invite.checkIn}
+
+
+
Check-out
+
{invite.checkOut}
+
+
+
Created
+
{invite.created}
+
+
+
Payment
+
{invite.paymentMethod ?? '-'}
+
+
+ +
+ {invite.invitation} + View +
+
+ )) + )} +
+); + +const PaginationControls = ({ + className = '', + currentPage, + totalPages, + onPrevious, + onNext, +}: { + className?: string; + currentPage: number; + totalPages: number; + onPrevious: () => void; + onNext: () => void; +}) => ( +
+
+ Page {currentPage} of {totalPages} +
+
+ + +
+
+); + +const MobileMenuDrawer = ({ + isOpen, + onClose, + menuItems, +}: { + isOpen: boolean; + onClose: () => void; + menuItems: MenuItem[]; +}) => ( +
+ +
+
+ {menuItems.map((drawerItem) => ( + + {drawerItem.withContainer ? ( + + } + /> + ) : ( + {drawerItem.alt} + )} + {drawerItem.label} + + ))} +
+ + +); + export default InvitationsPage; diff --git a/apps/web/src/components/guards/RoleGuard.tsx b/apps/web/src/components/guards/RoleGuard.tsx index 747ae67..00d10e1 100644 --- a/apps/web/src/components/guards/RoleGuard.tsx +++ b/apps/web/src/components/guards/RoleGuard.tsx @@ -1,81 +1,70 @@ 'use client'; import { useRouter } from 'next/navigation'; -import { useEffect } from 'react'; +import type { ReactNode } from 'react'; +import { useEffect, useMemo } from 'react'; +import { useAuth } from '~/hooks/auth/use-auth'; import { useUserRole } from '~/hooks/useUserRole'; import type { UserRole } from '~/types/roles'; interface RoleGuardProps { - children: React.ReactNode; + children: ReactNode; requiredRole: UserRole; fallbackPath?: string; } -export function RoleGuard({ children, requiredRole, fallbackPath = '/dashboard' }: RoleGuardProps) { - const { role, canAccessHostDashboard, isLoading } = useUserRole(); +export function RoleGuard({ + children, + requiredRole, + fallbackPath = '/become-host', +}: RoleGuardProps) { const router = useRouter(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + const { role, canAccessHostDashboard, isLoading: roleLoading } = useUserRole(); - useEffect(() => { - if (isLoading) { - return; - } + const isLoading = authLoading || roleLoading; - // Redirect unauthenticated users to main dashboard - if (!role) { - router.replace(fallbackPath); - return; - } + const hasAccess = useMemo(() => { + if (requiredRole === 'guest') return true; + if (!isAuthenticated) return false; + if (requiredRole === 'host') return canAccessHostDashboard; + return role === 'dual'; + }, [canAccessHostDashboard, isAuthenticated, requiredRole, role]); - if (requiredRole === 'host') { - // Host role requires: (role === 'host' || role === 'dual') AND canAccessHostDashboard - if (!(role === 'host' || role === 'dual') || !canAccessHostDashboard) { - router.replace(fallbackPath); - } - } else if (requiredRole === 'guest') { - // Guest role allows: guest, dual, or host without host-dashboard access - if (role !== 'guest' && role !== 'dual' && !(role === 'host' && !canAccessHostDashboard)) { - router.replace(fallbackPath); - } + useEffect(() => { + if (!isLoading && !hasAccess) { + router.replace(fallbackPath); } - }, [role, canAccessHostDashboard, isLoading, router, requiredRole, fallbackPath]); + }, [fallbackPath, hasAccess, isLoading, router]); - // Show loading state while checking authentication if (isLoading) { return ( -
-
-
-

Verifying access...

-
+
+ Checking access...
); } - // Show unauthorized UI if user doesn't have required access - if ( - requiredRole === 'host' && - (!(role === 'host' || role === 'dual') || !canAccessHostDashboard) - ) { + if (!hasAccess) { return ( -
-
-

Host Access Required

-

- You need to become a host to access this page. +

+
+

Host Access Required

+

+ You need a verified host profile with properties to access this page.

); } - // User has access, render children return <>{children}; } diff --git a/apps/web/src/hooks/auth/RoleGuard.tsx b/apps/web/src/hooks/auth/RoleGuard.tsx deleted file mode 100644 index 1d3b622..0000000 --- a/apps/web/src/hooks/auth/RoleGuard.tsx +++ /dev/null @@ -1,52 +0,0 @@ -// apps/web/src/hooks/auth/RoleGuard.tsx -'use client'; - -import { useRouter } from 'next/navigation'; -import { useEffect } from 'react'; -import { useUserRole } from '../useUserRole'; - -interface RoleGuardProps { - children: React.ReactNode; - requiredRole: 'guest' | 'host'; -} - -export default function RoleGuard({ children, requiredRole }: RoleGuardProps) { - const { role, canAccessHostDashboard, isLoading } = useUserRole(); - const router = useRouter(); - - useEffect(() => { - if (isLoading) { - return; - } - - // Redirect unauthenticated users to main dashboard - if (!role) { - router.replace('/dashboard'); - return; - } - - if (requiredRole === 'host') { - if (!(role === 'host' || role === 'dual') || !canAccessHostDashboard) { - router.replace('/dashboard'); - } - } else if (requiredRole === 'guest') { - // Allow: guest, dual, or host without host-dashboard access - if (role !== 'guest' && role !== 'dual' && !(role === 'host' && !canAccessHostDashboard)) { - router.replace('/dashboard'); - } - } - }, [role, canAccessHostDashboard, isLoading, router, requiredRole]); - - if (isLoading) { - return ( -
-
-
-

Loading...

-
-
- ); - } - - return <>{children}; -} diff --git a/apps/web/src/hooks/useUserRole.ts b/apps/web/src/hooks/useUserRole.ts deleted file mode 100644 index 349ee71..0000000 --- a/apps/web/src/hooks/useUserRole.ts +++ /dev/null @@ -1,102 +0,0 @@ -// apps/web/src/hooks/useUserRole.ts -'use client'; - -import { useEffect, useState } from 'react'; -import { useAuth } from './auth/use-auth'; - -// NOTE: The `profileAPI` is not yet implemented, so we're using a mock. -// import { profileAPI } from '~/services/api'; - -// Mock user profiles for development, keyed by user ID -const mockUserProfiles = { - 'user-1': { - role: 'host' as const, - hostStatus: 'verified' as const, - properties: [{ id: 1, name: 'Host Property' }], - }, - 'user-2': { - role: 'guest' as const, - hostStatus: 'unverified' as const, - properties: [], - }, - 'user-3': { - role: 'dual' as const, - hostStatus: 'verified' as const, - properties: [{ id: 2, name: 'Dual User Property' }], - }, -}; - -// Mock implementation of profileAPI for development -const profileAPI = { - getProfile: async (userId: string) => { - // In a real app, this would be a network request. - // For development, we simulate a delay and return a mock profile. - await new Promise((resolve) => setTimeout(resolve, 500)); - - if (process.env.NODE_ENV === 'development') { - // @ts-ignore - const profile = mockUserProfiles[userId]; - if (profile) { - return profile; - } - } - - // Default profile for production or if user not in mock data - return { - role: 'guest' as const, - hostStatus: 'unverified' as const, - properties: [], - }; - }, -}; - -interface Property { - id: number; - name: string; -} - -export function useUserRole() { - const { user, isAuthenticated } = useAuth(); - const [role, setRole] = useState<'guest' | 'host' | 'dual' | null>(null); - const [hostStatus, setHostStatus] = useState<'verified' | 'unverified' | 'pending' | null>(null); - const [properties, setProperties] = useState([]); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - async function fetchUserRole() { - if (isAuthenticated && user) { - setIsLoading(true); - try { - // The user object from useAuth needs to have an `id` property. - // For now, we'll assume it does. If not, this will need adjustment. - // @ts-ignore - const userProfile = await profileAPI.getProfile(user.id || 'user-2'); // Default to user-2 for demo - - setRole(userProfile.role); - setHostStatus(userProfile.hostStatus); - setProperties(userProfile.properties); - } catch (error) { - console.error('Failed to fetch user role:', error); - setRole('guest'); // Default to 'guest' on error - setHostStatus('unverified'); - setProperties([]); - } finally { - setIsLoading(false); - } - } else if (!isAuthenticated) { - setRole(null); - setHostStatus(null); - setProperties([]); - setIsLoading(false); - } - } - - fetchUserRole(); - }, [isAuthenticated, user]); - - const hasProperties = properties.length > 0; - const canAccessHostDashboard = - (role === 'host' || role === 'dual') && hostStatus === 'verified' && hasProperties; - - return { role, canAccessHostDashboard, isLoading, hasProperties }; -}