diff --git a/app/(landing)/projects/[id]/page.tsx b/app/(landing)/projects/[id]/page.tsx index 10e29c235..e1aee744d 100644 --- a/app/(landing)/projects/[id]/page.tsx +++ b/app/(landing)/projects/[id]/page.tsx @@ -1,16 +1,16 @@ import { ProjectLayout } from '@/components/project-details/project-layout'; import { ProjectLoading } from '@/components/project-details/project-loading'; import { notFound } from 'next/navigation'; -import { getCrowdfundingProject, getProjectDetails } from '@/lib/api/project'; -import { CrowdfundingProject, CrowdfundData } from '@/lib/api/types'; +import { getCrowdfundingProject } from '@/lib/api/project'; +import type { CrowdfundingProject, CrowdfundData } from '@/lib/api/types'; import { Suspense } from 'react'; + interface ProjectPageProps { params: Promise<{ id: string; }>; } -// Transform crowdfunding project data to match ProjectLayout interface function transformCrowdfundingProject( project: CrowdfundingProject, crowdfund: CrowdfundData @@ -19,11 +19,12 @@ function transformCrowdfundingProject( ? `${project.creator.profile.firstName} ${project.creator.profile.lastName}` : 'Unknown Creator'; - const daysToDeadline = project.funding?.endDate + const deadlineDate = crowdfund.voteDeadline || project.funding?.endDate; + const daysToDeadline = deadlineDate ? Math.max( 0, Math.ceil( - (new Date(project.funding.endDate).getTime() - new Date().getTime()) / + (new Date(deadlineDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24) ) ) @@ -32,12 +33,11 @@ function transformCrowdfundingProject( return { project: { ...project, - daysToDeadline: Math.max(0, daysToDeadline), - // Add additional fields without overriding the original creator + daysToDeadline, additionalCreator: { name: creatorName, role: 'OWNER', - avatar: '/user.png', // Default avatar, could be enhanced with actual user avatar + avatar: '/user.png', }, links: [ ...(project.githubUrl @@ -55,6 +55,7 @@ function transformCrowdfundingProject( icon: link.platform.toLowerCase(), })) || []), ], + votes: crowdfund.totalVotes || 0, }, crowdfund, }; @@ -65,144 +66,18 @@ async function ProjectContent({ id }: { id: string }) { let error: string | null = null; try { - // Try to fetch as crowdfunding project first - try { - const response = await getCrowdfundingProject(id); - - if (response.success && response.data) { - projectData = transformCrowdfundingProject( - response.data.project, - response.data.crowdfund - ); - } else { - throw new Error('Failed to fetch crowdfunding project'); - } - } catch { - // If crowdfunding project fails, try regular project - try { - const regularProject = await getProjectDetails(id); - - // Transform regular project data if needed - // For regular projects, we need to create a mock CrowdfundingProject structure - const mockProject: CrowdfundingProject = { - _id: regularProject.id || regularProject._id, - title: regularProject.title, - description: regularProject.description, - category: regularProject.category, - owner: { - type: 'individual', - }, - vision: regularProject.description, - githubUrl: regularProject.githubUrl, - projectWebsite: regularProject.projectWebsite, - demoVideo: regularProject.demoVideo, - socialLinks: regularProject.socialLinks, - status: regularProject.status, - creator: { - _id: 'unknown', - profile: { - firstName: regularProject.ownerName?.split(' ')[0] || 'Unknown', - lastName: regularProject.ownerName?.split(' ')[1] || 'Unknown', - username: regularProject.ownerUsername || 'unknown', - }, - }, - contact: { - primary: '', - backup: '', - }, - createdAt: regularProject.createdAt, - updatedAt: regularProject.updatedAt || regularProject.createdAt, - funding: { - goal: 0, - raised: 0, - currency: 'USD', - endDate: new Date( - Date.now() + 30 * 24 * 60 * 60 * 1000 - ).toISOString(), - contributors: [], - }, - voting: { - startDate: new Date().toISOString(), - endDate: new Date( - Date.now() + 30 * 24 * 60 * 60 * 1000 - ).toISOString(), - totalVotes: 0, - positiveVotes: 0, - negativeVotes: 0, - voters: [], - }, - milestones: [], - team: [], - media: { - banner: regularProject.image, - logo: regularProject.image, - thumbnail: regularProject.image, - }, - documents: { - whitepaper: '', - pitchDeck: '', - }, - tags: [], - grant: { - isGrant: false, - totalBudget: 0, - totalDisbursed: 0, - proposalsReceived: 0, - proposalsApproved: 0, - status: 'pending', - applications: [], - }, - summary: regularProject.description, - type: 'crowdfund', - votes: 0, - stakeholders: { - serviceProvider: '', - approver: '', - releaseSigner: '', - disputeResolver: '', - receiver: '', - platformAddress: '', - }, - trustlessWorkStatus: 'pending', - escrowType: 'pending', - __v: 0, - }; + const response = await getCrowdfundingProject(id); - const mockCrowdfund: CrowdfundData = { - _id: 'mock-crowdfund', - projectId: regularProject.id || regularProject._id, - thresholdVotes: 0, - voteDeadline: new Date( - Date.now() + 30 * 24 * 60 * 60 * 1000 - ).toISOString(), - totalVotes: 0, - voteProgress: 0, - status: regularProject.status, - createdAt: regularProject.createdAt, - updatedAt: regularProject.updatedAt || regularProject.createdAt, - __v: 0, - isVotingActive: false, - id: 'mock-crowdfund', - }; - - projectData = { - project: { - ...mockProject, - daysToDeadline: 30, - additionalCreator: { - name: regularProject.ownerName || 'Unknown Creator', - role: 'OWNER', - avatar: regularProject.ownerAvatar || '/user.png', - }, - links: [], - }, - crowdfund: mockCrowdfund, - }; - } catch { - error = 'Project not found'; - } + if (response.success && response.data) { + projectData = transformCrowdfundingProject( + response.data.project, + response.data.crowdfund + ); + } else { + throw new Error(response.message || 'Failed to fetch project'); } - } catch { + } catch (err) { + console.error('Error fetching project:', err); error = 'Failed to fetch project data'; } @@ -212,32 +87,6 @@ async function ProjectContent({ id }: { id: string }) { return (
- {/*
-
-
- - - - - - - -
-
-
*/} -
([]); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(null); - const [currentPage, setCurrentPage] = useState(1); - const [hasMore, setHasMore] = useState(true); - const [filters, setFilters] = useState({}); - const [searchTerm, setSearchTerm] = useState(''); - - // Debounce search term to avoid excessive API calls - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm); - - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - - return () => { - clearTimeout(handler); - }; - }, [searchTerm]); - - // Get project deadline in days - const getProjectDeadline = useCallback( - (project: CrowdfundingProject): number => { - if (project.status === 'idea') { - return new Date(project.voting.endDate).getTime(); - } else if ( - project.status === 'active' || - project.status === 'completed' - ) { - return new Date(project.funding.endDate).getTime(); - } - return 0; - }, - [] - ); - - // Sort projects based on sort option - const sortProjects = useCallback( - ( - projects: CrowdfundingProject[], - sortOption: SortOption - ): CrowdfundingProject[] => { - const sorted = [...projects]; - - switch (sortOption) { - case 'newest': - return sorted.sort( - (a, b) => - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ); - case 'oldest': - return sorted.sort( - (a, b) => - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - ); - case 'funding_goal_high': - return sorted.sort((a, b) => b.funding.goal - a.funding.goal); - case 'funding_goal_low': - return sorted.sort((a, b) => a.funding.goal - b.funding.goal); - case 'deadline_soon': - return sorted.sort((a, b) => { - const aDeadline = getProjectDeadline(a); - const bDeadline = getProjectDeadline(b); - return aDeadline - bDeadline; - }); - case 'deadline_far': - return sorted.sort((a, b) => { - const aDeadline = getProjectDeadline(a); - const bDeadline = getProjectDeadline(b); - return bDeadline - aDeadline; - }); - default: - return sorted; - } - }, - [getProjectDeadline] - ); - - const fetchProjects = useCallback( - async (page = 1, newFilters: ProjectFilters = {}, append = false) => { - try { - if (append) { - setLoadingMore(true); - } else { - setLoading(true); - } - setError(null); - - const response = await getCrowdfundingProjects(page, 9, { - category: newFilters.category, - status: newFilters.status, - }); - - let fetchedProjects = response.data.projects; - - // Apply client-side search filtering - if (newFilters.search) { - const searchLower = newFilters.search.toLowerCase(); - fetchedProjects = fetchedProjects.filter( - project => - project.title.toLowerCase().includes(searchLower) || - project.vision.toLowerCase().includes(searchLower) || - project.description.toLowerCase().includes(searchLower) || - project.category.toLowerCase().includes(searchLower) || - `${project.creator.profile.firstName} ${project.creator.profile.lastName}` - .toLowerCase() - .includes(searchLower) - ); - } - - // Apply client-side sorting - if (newFilters.sort) { - fetchedProjects = sortProjects(fetchedProjects, newFilters.sort); - } - - if (append) { - setProjects(prev => [...prev, ...fetchedProjects]); - } else { - setProjects(fetchedProjects); - } - - setHasMore(response.data.pagination.pages > page); - } catch (err) { - // eslint-disable-next-line no-console - console.error('Error fetching projects:', err); - setError('Failed to fetch projects. Please try again.'); - } finally { - setLoading(false); - setLoadingMore(false); - } - }, - [sortProjects] - ); - - // Update filters when debounced search term changes - useEffect(() => { - setFilters(prev => ({ ...prev, search: debouncedSearchTerm })); - }, [debouncedSearchTerm]); - - // Fetch projects when filters change - useEffect(() => { - setCurrentPage(1); - setHasMore(true); - fetchProjects(1, filters); - }, [filters, fetchProjects]); - - const handleSearch = useCallback((searchTerm: string) => { - setSearchTerm(searchTerm); - }, []); - - const handleSort = useCallback((sortType: string) => { - setFilters(prev => ({ ...prev, sort: sortType as SortOption })); - }, []); - - const handleStatus = useCallback((status: string) => { - setFilters(prev => ({ - ...prev, - status: status === 'all' ? undefined : status, - })); - }, []); - - const handleCategory = useCallback((category: string) => { - setFilters(prev => ({ - ...prev, - category: category === 'all' ? undefined : category, - })); - }, []); - - const handleLoadMore = useCallback(() => { - const nextPage = currentPage + 1; - setCurrentPage(nextPage); - fetchProjects(nextPage, filters, true); - }, [currentPage, filters, fetchProjects]); - - // Transform project data for ProjectCard component - const transformProjectForCard = useCallback( - (project: CrowdfundingProject) => { - let deadlineInDays: number | null = null; - - try { - if (project.status === 'idea') { - const now = new Date(); - const end = new Date(project.voting.endDate); - deadlineInDays = Math.ceil( - (end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) - ); - } else if ( - project.status === 'active' || - project.status === 'completed' - ) { - const now = new Date(); - const end = new Date(project.funding.endDate); - deadlineInDays = Math.ceil( - (end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) - ); - } - } catch (error) { - // eslint-disable-next-line no-console - console.warn( - 'Error calculating deadline for project:', - project._id, - error - ); - deadlineInDays = null; - } - - // Map project status to card status - let cardStatus: 'Validation' | 'Funding' | 'Funded' | 'Completed' = - 'Funding'; - if (project.status === 'draft' || project.status === 'idea') { - cardStatus = 'Validation'; - } else if (project.status === 'active') { - cardStatus = 'Funding'; - } else if (project.status === 'completed') { - cardStatus = 'Completed'; - } - - return { - projectId: project._id, - creatorName: `${project.creator.profile.firstName} ${project.creator.profile.lastName}`, - creatorLogo: '/avatar.png', // Default avatar, should be from project.creator.avatar - projectImage: - project.media?.logo || - project.media?.logo || - '/landing/explore/project-placeholder-1.png', - projectTitle: project.title, - projectDescription: project.vision || project.description, - status: cardStatus, - deadlineInDays: deadlineInDays || 0, - funding: { - current: project.funding?.raised || 0, - goal: project.funding?.goal || 0, - currency: project.funding?.currency || 'USDC', - }, - votes: - project.status === 'idea' - ? { - current: project.votes || 0, - goal: project.voting?.totalVotes || 100, - } - : undefined, - }; - }, - [] - ); - - // Memoized project cards to prevent unnecessary re-renders - const projectCards = useMemo(() => { - return projects.map(project => { - const cardData = transformProjectForCard(project); - return ; - }); - }, [projects, transformProjectForCard]); - - // Load More Button component - const LoadMoreButton = useMemo(() => { - if (!hasMore) return null; - - return ( -
- -
- ); - }, [hasMore, loadingMore, handleLoadMore]); +import ProjectsClient from '@/components/project/ProjectsPage'; +export default function ProjectsPage() { return (
- - -
- {/* Results Summary */} - {!loading && !error && ( -
-
- {projects.length > 0 ? ( - - Showing {projects.length} project - {projects.length !== 1 ? 's' : ''} - {filters.search && ` for "${filters.search}"`} - {filters.category && ` in ${filters.category}`} - {filters.status && ` with status ${filters.status}`} - - ) : ( - No projects found - )} -
- {filters.search && ( - - )} -
- )} - - {/* Loading State */} - {loading && ( -
- -

Loading projects...

-
- )} - - {/* Error State */} - {error && ( -
-
-
⚠️
-

- Something went wrong -

-

{error}

- -
-
- )} - - {/* Empty State */} - {!loading && !error && projects.length === 0 && ( -
-
-
🔍
-

- No projects found -

-

- {filters.search || filters.category || filters.status - ? 'Try adjusting your filters or search terms' - : 'No projects are available at the moment'} -

- {(filters.search || filters.category || filters.status) && ( - - )} -
-
- )} - - {/* Projects Grid */} - {!loading && !error && projects.length > 0 && ( - <> -
- {projectCards} -
- {LoadMoreButton} - - )} -
+
); } - -export default ProjectsPage; diff --git a/app/globals.css b/app/globals.css index 2899e4edd..ce48e5a7b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -80,6 +80,10 @@ z-index: 2; } +body { + scroll-behavior: smooth; +} + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); diff --git a/components/Project-Page-Hero.tsx b/components/Project-Page-Hero.tsx index f144e5134..80e9bdb18 100644 --- a/components/Project-Page-Hero.tsx +++ b/components/Project-Page-Hero.tsx @@ -1,7 +1,9 @@ +'use client'; import React from 'react'; import Image from 'next/image'; import { ArrowDown } from 'lucide-react'; import { BoundlessButton } from './buttons/BoundlessButton'; +import Link from 'next/link'; export default function ProjectPageHero() { return ( @@ -41,15 +43,17 @@ export default function ProjectPageHero() { Validated by the community. Backed milestone by milestone.

- - - Start Exploring Projects - - - + + + + Start Exploring Projects + + + +
diff --git a/components/landing-page/project/ProjectCard.tsx b/components/landing-page/project/ProjectCard.tsx index caa81b54d..fd64e1ed9 100644 --- a/components/landing-page/project/ProjectCard.tsx +++ b/components/landing-page/project/ProjectCard.tsx @@ -1,3 +1,4 @@ +'use client'; import { Progress } from '@/components/ui/progress'; import { formatNumber } from '@/lib/utils'; import { useRouter } from 'nextjs-toploader/app'; diff --git a/components/project-details/comment-section/comments-empty-state.tsx b/components/project-details/comment-section/comments-empty-state.tsx index df56abf52..77242d7b1 100644 --- a/components/project-details/comment-section/comments-empty-state.tsx +++ b/components/project-details/comment-section/comments-empty-state.tsx @@ -11,8 +11,8 @@ export function CommentsEmptyState({ onAddComment }: CommentsEmptyStateProps) { return (
-
- +
+

Be the first to Leave a Comment diff --git a/components/project-details/project-layout.tsx b/components/project-details/project-layout.tsx index f4ecf507e..c24747968 100644 --- a/components/project-details/project-layout.tsx +++ b/components/project-details/project-layout.tsx @@ -13,7 +13,7 @@ import { ProjectComments } from './comment-section/project-comments'; import ProjectMilestone from './project-milestone'; import ProjectVoters from './project-voters'; import ProjectBackers from './project-backers'; -import FundProject from '@/components/modals/fund-project'; +// import FundProject from '@/components/modals/fund-project'; interface ProjectLayoutProps { project: CrowdfundingProject & { @@ -344,7 +344,7 @@ export function ProjectLayout({ project }: ProjectLayoutProps) {

- {}} /> + {/* {}} /> */}
); } diff --git a/components/project-details/project-sidebar.tsx b/components/project-details/project-sidebar.tsx index d4d72bf51..51cc8de7f 100644 --- a/components/project-details/project-sidebar.tsx +++ b/components/project-details/project-sidebar.tsx @@ -1,3 +1,5 @@ +'use client'; + import { Calendar, Github, @@ -7,12 +9,14 @@ import { Share2, UserPlus, ArrowUp, + DollarSign, } from 'lucide-react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; import Image from 'next/image'; -import { CrowdfundingProject } from '@/lib/api/types'; +import type { CrowdfundingProject } from '@/lib/api/types'; +import { voteProject } from '@/lib/api/project'; interface ProjectSidebarProps { project: CrowdfundingProject & { @@ -142,7 +146,10 @@ export function ProjectSidebar({ {/* Action Buttons - Responsive widths */}
- @@ -159,7 +166,12 @@ export function ProjectSidebar({ Fund */} - + {project.status === 'Funding' && ( + + )} + )} +
+ )} + + {/* Loading State */} + {loading && ( +
+ +

Loading projects...

+
+ )} + + {/* Error State */} + {error && ( +
+
+
⚠️
+

+ Something went wrong +

+

{error}

+ +
+
+ )} + + {/* Empty State */} + {!loading && !error && projects.length === 0 && ( +
+
+
🔍
+

+ No projects found +

+

+ {filters.search || filters.category || filters.status + ? 'Try adjusting your filters or search terms' + : 'No projects are available at the moment'} +

+ {(filters.search || filters.category || filters.status) && ( + + )} +
+
+ )} + + {/* Projects Grid */} + {!loading && !error && projects.length > 0 && ( + <> +
+ {projectCards} +
+ + {/* Load More Button */} + {hasMore && ( +
+ +
+ )} + + )} + + + ); +} diff --git a/components/projects/ExploreHeader.tsx b/components/projects/ExploreHeader.tsx index 091472943..94fe2e510 100644 --- a/components/projects/ExploreHeader.tsx +++ b/components/projects/ExploreHeader.tsx @@ -87,7 +87,10 @@ const ExploreHeader = ({
-

+

Explore Boundless Projects

diff --git a/hooks/project/use-project-filters.ts b/hooks/project/use-project-filters.ts new file mode 100644 index 000000000..69349d6fe --- /dev/null +++ b/hooks/project/use-project-filters.ts @@ -0,0 +1,87 @@ +'use client'; + +import * as React from 'react'; +import { useDebounce } from '../use-debounce'; + +type SortOption = + | 'newest' + | 'oldest' + | 'funding_goal_high' + | 'funding_goal_low' + | 'deadline_soon' + | 'deadline_far'; + +interface ProjectFilters { + category?: string; + status?: string; + search?: string; + sort?: SortOption; +} + +interface UseProjectFiltersReturn { + filters: ProjectFilters; + searchTerm: string; + debouncedSearchTerm: string; + handleSearch: (searchTerm: string) => void; + handleSort: (sortType: string) => void; + handleStatus: (status: string) => void; + handleCategory: (category: string) => void; + clearSearch: () => void; + clearAllFilters: () => void; +} + +export function useProjectFilters( + initialFilters: ProjectFilters = {} +): UseProjectFiltersReturn { + const [filters, setFilters] = React.useState(initialFilters); + const [searchTerm, setSearchTerm] = React.useState(''); + const debouncedSearchTerm = useDebounce(searchTerm, 500); + + // Update filters when debounced search term changes + React.useEffect(() => { + setFilters(prev => ({ ...prev, search: debouncedSearchTerm })); + }, [debouncedSearchTerm]); + + const handleSearch = React.useCallback((searchTerm: string) => { + setSearchTerm(searchTerm); + }, []); + + const handleSort = React.useCallback((sortType: string) => { + setFilters(prev => ({ ...prev, sort: sortType as SortOption })); + }, []); + + const handleStatus = React.useCallback((status: string) => { + setFilters(prev => ({ + ...prev, + status: status === 'all' ? undefined : status, + })); + }, []); + + const handleCategory = React.useCallback((category: string) => { + setFilters(prev => ({ + ...prev, + category: category === 'all' ? undefined : category, + })); + }, []); + + const clearSearch = React.useCallback(() => { + setSearchTerm(''); + }, []); + + const clearAllFilters = React.useCallback(() => { + setSearchTerm(''); + setFilters({}); + }, []); + + return { + filters, + searchTerm, + debouncedSearchTerm, + handleSearch, + handleSort, + handleStatus, + handleCategory, + clearSearch, + clearAllFilters, + }; +} diff --git a/hooks/project/use-project-transform.ts b/hooks/project/use-project-transform.ts new file mode 100644 index 000000000..4b5d18902 --- /dev/null +++ b/hooks/project/use-project-transform.ts @@ -0,0 +1,98 @@ +'use client'; + +import * as React from 'react'; +import type { CrowdfundingProject } from '@/lib/api/types'; + +interface TransformedProject { + projectId: string; + creatorName: string; + creatorLogo: string; + projectImage: string; + projectTitle: string; + projectDescription: string; + status: 'Validation' | 'Funding' | 'Funded' | 'Completed'; + deadlineInDays: number; + funding: { + current: number; + goal: number; + currency: string; + }; + votes?: { + current: number; + goal: number; + }; +} + +export function useProjectTransform() { + const transformProjectForCard = React.useCallback( + (project: CrowdfundingProject): TransformedProject => { + let deadlineInDays: number | null = null; + + try { + if (project.status === 'idea') { + const now = new Date(); + const end = new Date(project.voting.endDate); + deadlineInDays = Math.ceil( + (end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) + ); + } else if ( + project.status === 'active' || + project.status === 'completed' + ) { + const now = new Date(); + const end = new Date(project.funding.endDate); + deadlineInDays = Math.ceil( + (end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) + ); + } + } catch (error) { + console.warn( + 'Error calculating deadline for project:', + project._id, + error + ); + deadlineInDays = null; + } + + // Map project status to card status + let cardStatus: 'Validation' | 'Funding' | 'Funded' | 'Completed' = + 'Funding'; + if (project.status === 'draft' || project.status === 'idea') { + cardStatus = 'Validation'; + } else if (project.status === 'active') { + cardStatus = 'Funding'; + } else if (project.status === 'completed') { + cardStatus = 'Completed'; + } + + return { + projectId: project._id, + creatorName: `${project.creator.profile.firstName} ${project.creator.profile.lastName}`, + creatorLogo: '/avatar.png', + projectImage: + project.media?.logo || + project.media?.logo || + '/landing/explore/project-placeholder-1.png', + projectTitle: project.title, + projectDescription: project.vision || project.description, + status: cardStatus, + deadlineInDays: deadlineInDays || 0, + funding: { + current: project.funding?.raised || 0, + goal: project.funding?.goal || 0, + currency: project.funding?.currency || 'USDC', + }, + votes: + project.status === 'idea' + ? { + current: project.votes || 0, + goal: project.voting?.totalVotes || 100, + } + : undefined, + }; + }, + [] + ); + + return { transformProjectForCard }; +} diff --git a/hooks/project/use-project.ts b/hooks/project/use-project.ts new file mode 100644 index 000000000..53544f63c --- /dev/null +++ b/hooks/project/use-project.ts @@ -0,0 +1,196 @@ +'use client'; + +import * as React from 'react'; +import { getCrowdfundingProjects } from '@/lib/api/project'; +import type { CrowdfundingProject } from '@/lib/api/types'; + +type SortOption = + | 'newest' + | 'oldest' + | 'funding_goal_high' + | 'funding_goal_low' + | 'deadline_soon' + | 'deadline_far'; + +interface ProjectFilters { + category?: string; + status?: string; + search?: string; + sort?: SortOption; +} + +interface UseProjectsOptions { + initialPage?: number; + pageSize?: number; + initialFilters?: ProjectFilters; +} + +interface UseProjectsReturn { + projects: CrowdfundingProject[]; + loading: boolean; + loadingMore: boolean; + error: string | null; + hasMore: boolean; + currentPage: number; + filters: ProjectFilters; + setFilters: React.Dispatch>; + loadMore: () => void; + refetch: () => void; +} + +export function useProjects( + options: UseProjectsOptions = {} +): UseProjectsReturn { + const { initialPage = 1, pageSize = 9, initialFilters = {} } = options; + + const [projects, setProjects] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [loadingMore, setLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + const [currentPage, setCurrentPage] = React.useState(initialPage); + const [hasMore, setHasMore] = React.useState(true); + const [filters, setFilters] = React.useState(initialFilters); + + // Get project deadline in milliseconds + const getProjectDeadline = React.useCallback( + (project: CrowdfundingProject): number => { + if (project.status === 'idea') { + return new Date(project.voting.endDate).getTime(); + } else if ( + project.status === 'active' || + project.status === 'completed' + ) { + return new Date(project.funding.endDate).getTime(); + } + return 0; + }, + [] + ); + + // Sort projects based on sort option + const sortProjects = React.useCallback( + ( + projects: CrowdfundingProject[], + sortOption: SortOption + ): CrowdfundingProject[] => { + const sorted = [...projects]; + + switch (sortOption) { + case 'newest': + return sorted.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + case 'oldest': + return sorted.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + case 'funding_goal_high': + return sorted.sort((a, b) => b.funding.goal - a.funding.goal); + case 'funding_goal_low': + return sorted.sort((a, b) => a.funding.goal - b.funding.goal); + case 'deadline_soon': + return sorted.sort((a, b) => { + const aDeadline = getProjectDeadline(a); + const bDeadline = getProjectDeadline(b); + return aDeadline - bDeadline; + }); + case 'deadline_far': + return sorted.sort((a, b) => { + const aDeadline = getProjectDeadline(a); + const bDeadline = getProjectDeadline(b); + return bDeadline - aDeadline; + }); + default: + return sorted; + } + }, + [getProjectDeadline] + ); + + const fetchProjects = React.useCallback( + async (page: number, currentFilters: ProjectFilters, append = false) => { + try { + if (append) { + setLoadingMore(true); + } else { + setLoading(true); + } + setError(null); + + const response = await getCrowdfundingProjects(page, pageSize, { + category: currentFilters.category, + status: currentFilters.status, + }); + + let fetchedProjects = response.data.projects; + + // Apply client-side search filtering + if (currentFilters.search) { + const searchLower = currentFilters.search.toLowerCase(); + fetchedProjects = fetchedProjects.filter( + project => + project.title.toLowerCase().includes(searchLower) || + project.vision.toLowerCase().includes(searchLower) || + project.description.toLowerCase().includes(searchLower) || + project.category.toLowerCase().includes(searchLower) || + `${project.creator.profile.firstName} ${project.creator.profile.lastName}` + .toLowerCase() + .includes(searchLower) + ); + } + + // Apply client-side sorting + if (currentFilters.sort) { + fetchedProjects = sortProjects(fetchedProjects, currentFilters.sort); + } + + if (append) { + setProjects(prev => [...prev, ...fetchedProjects]); + } else { + setProjects(fetchedProjects); + } + + setHasMore(response.data.pagination.pages > page); + } catch (err) { + console.error('Error fetching projects:', err); + setError('Failed to fetch projects. Please try again.'); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, + [pageSize, sortProjects] + ); + + // Fetch projects when filters change + React.useEffect(() => { + setCurrentPage(1); + setHasMore(true); + fetchProjects(1, filters); + }, [filters, fetchProjects]); + + const loadMore = React.useCallback(() => { + const nextPage = currentPage + 1; + setCurrentPage(nextPage); + fetchProjects(nextPage, filters, true); + }, [currentPage, filters, fetchProjects]); + + const refetch = React.useCallback(() => { + fetchProjects(currentPage, filters); + }, [currentPage, filters, fetchProjects]); + + return { + projects, + loading, + loadingMore, + error, + hasMore, + currentPage, + filters, + setFilters, + loadMore, + refetch, + }; +} diff --git a/lib/api/project.ts b/lib/api/project.ts index 11352cdb2..37316a8af 100644 --- a/lib/api/project.ts +++ b/lib/api/project.ts @@ -227,3 +227,10 @@ export const confirmProjectFunding = async ( ); return res.data; }; +export const voteProject = async ( + projectId: string, + value: 1 | -1 = 1 +): Promise => { + const res = await api.post(`/projects/${projectId}/vote`, { value }); + return res.data; +}; diff --git a/lib/mock.ts b/lib/mock.ts index 227822c39..d546a209d 100644 --- a/lib/mock.ts +++ b/lib/mock.ts @@ -28,7 +28,7 @@ export const mockProjects: Project[] = [ category: 'defi', type: 'crowdfunding', amount: 85000, - status: 'approved', + status: 'funding', createdAt: '2024-01-10T09:15:00Z', updatedAt: '2024-01-18T16:45:00Z', },