From 57b86305621c3d75d6398d08dceb4f42436ca5c7 Mon Sep 17 00:00:00 2001 From: Nnaji Benjamin <60315147+Benjtalkshow@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:02:52 +0100 Subject: [PATCH 01/45] Modify project (#319) * fix: fix hero svg * fix: revert backed by component * fix: modify md editor * fix: make projects page server components * fix: refactor project details page --- app/(landing)/projects/[id]/page.tsx | 189 +------- app/(landing)/projects/page.tsx | 412 +----------------- app/globals.css | 4 + components/Project-Page-Hero.tsx | 22 +- .../landing-page/project/ProjectCard.tsx | 1 + .../comment-section/comments-empty-state.tsx | 4 +- components/project-details/project-layout.tsx | 4 +- .../project-details/project-sidebar.tsx | 18 +- components/project/ProjectsPage.tsx | 156 +++++++ components/projects/ExploreHeader.tsx | 5 +- hooks/project/use-project-filters.ts | 87 ++++ hooks/project/use-project-transform.ts | 98 +++++ hooks/project/use-project.ts | 196 +++++++++ lib/api/project.ts | 7 + lib/mock.ts | 2 +- 15 files changed, 608 insertions(+), 597 deletions(-) create mode 100644 components/project/ProjectsPage.tsx create mode 100644 hooks/project/use-project-filters.ts create mode 100644 hooks/project/use-project-transform.ts create mode 100644 hooks/project/use-project.ts 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', }, From 5873a4e77baad6b297acd38352fcb325f1bea34f Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Sun, 12 Oct 2025 05:48:19 -0700 Subject: [PATCH 02/45] Collins (#321) * refactor: update project components and API for voting functionality - Adjust layout and styling in ProjectsPage and ProjectCard components. - Enhance ProjectSidebar to include crowdfund data and voting logic. - Implement new API methods for voting, retrieving votes, and removing votes. - Update types for vote handling in the API. - Clean up unused code and improve responsiveness across components. * chore: update project dependencies and enhance project details layout --- .../[id]/milestone/[milestoneId]/page.tsx | 59 +- app/(landing)/projects/[id]/page.tsx | 6 +- app/(landing)/projects/page.tsx | 2 +- app/globals.css | 39 + components/EmptyState.tsx | 2 +- components/Project-Page-Hero.tsx | 2 +- .../project/CreateProjectModal/Milestones.tsx | 390 +- .../landing-page/project/ProjectCard.tsx | 18 +- components/modals/fund-project/Amount.tsx | 37 +- components/modals/fund-project/Confirm.tsx | 107 +- components/modals/fund-project/Header.tsx | 37 +- components/modals/fund-project/index.tsx | 141 +- .../project-details/project-backers/Empty.tsx | 2 +- .../project-details/project-backers/index.tsx | 56 +- components/project-details/project-layout.tsx | 27 +- .../project-milestone/index.tsx | 227 +- .../project-milestone/milestone-card.tsx | 4 + .../milestone-details/MilestoneDetails.tsx | 236 +- .../milestone-details/MilestoneLinks.tsx | 230 +- .../milestone-details/MilstoneOverview.tsx | 222 +- .../project-details/project-sidebar.tsx | 260 - .../project-sidebar/ProjectSidebarActions.tsx | 146 + .../project-sidebar/ProjectSidebarCreator.tsx | 40 + .../project-sidebar/ProjectSidebarHeader.tsx | 69 + .../project-sidebar/ProjectSidebarLinks.tsx | 91 + .../ProjectSidebarProgress.tsx | 113 + .../project-sidebar/SharePopup.tsx | 151 + .../project-details/project-sidebar/index.tsx | 67 + .../project-details/project-sidebar/types.ts | 76 + .../project-details/project-sidebar/utils.ts | 20 + components/project-details/project-team.tsx | 134 +- .../project-details/project-voters/Empty.tsx | 15 +- .../project-details/project-voters/index.tsx | 160 +- components/projects/ExploreHeader.tsx | 2 +- components/ui/timeline/Timeline.tsx | 2 +- components/ui/timeline/TimelineItem.tsx | 1 + components/ui/timeline/types.ts | 1 + hooks/project/use-project-transform.ts | 8 +- hooks/project/use-project.ts | 3 +- lib/api/project.ts | 47 +- lib/api/types.ts | 63 + package-lock.json | 8322 +++++++++++------ package.json | 7 +- 43 files changed, 7410 insertions(+), 4232 deletions(-) delete mode 100644 components/project-details/project-sidebar.tsx create mode 100644 components/project-details/project-sidebar/ProjectSidebarActions.tsx create mode 100644 components/project-details/project-sidebar/ProjectSidebarCreator.tsx create mode 100644 components/project-details/project-sidebar/ProjectSidebarHeader.tsx create mode 100644 components/project-details/project-sidebar/ProjectSidebarLinks.tsx create mode 100644 components/project-details/project-sidebar/ProjectSidebarProgress.tsx create mode 100644 components/project-details/project-sidebar/SharePopup.tsx create mode 100644 components/project-details/project-sidebar/index.tsx create mode 100644 components/project-details/project-sidebar/types.ts create mode 100644 components/project-details/project-sidebar/utils.ts diff --git a/app/(landing)/projects/[id]/milestone/[milestoneId]/page.tsx b/app/(landing)/projects/[id]/milestone/[milestoneId]/page.tsx index ed19d5772..233e73089 100644 --- a/app/(landing)/projects/[id]/milestone/[milestoneId]/page.tsx +++ b/app/(landing)/projects/[id]/milestone/[milestoneId]/page.tsx @@ -3,22 +3,64 @@ import MilstoneOverview from '@/components/project-details/project-milestone/mil import MilestoneDetails from '@/components/project-details/project-milestone/milestone-details/MilestoneDetails'; import MilestoneLinks from '@/components/project-details/project-milestone/milestone-details/MilestoneLinks'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { getProjects } from '@/lib/api/project'; +import { CrowdfundingProject } from '@/lib/api/types'; interface MilestonePageProps { params: Promise<{ - milestoneId: string; // This will be the milestone ID + id: string; // Project ID + milestoneId: string; // Milestone ID }>; } const MilestonePage = async ({ params }: MilestonePageProps) => { - const { milestoneId } = await params; + const { id: projectId, milestoneId } = await params; + + // Fetch project data + let project: CrowdfundingProject | null = null; + let milestone = null; + + try { + const projectResponse = await getProjects(); + if (projectResponse.projects) { + // Find the specific project + const foundProject = projectResponse.projects.find( + (p: { id: string }) => p.id === projectId + ); + project = foundProject + ? (foundProject as unknown as CrowdfundingProject) + : null; + + // Find the specific milestone + milestone = project?.milestones?.find( + (m: { _id: string }) => m._id === milestoneId + ); + } + } catch { + // Handle error silently + } + + // If milestone not found, show error state + if (!milestone) { + return ( +
+
+

+ Milestone Not Found +

+

+ The requested milestone could not be found. +

+
+
+ ); + } return (
- +
- {/*
*/}
@@ -37,13 +79,16 @@ const MilestonePage = async ({ params }: MilestonePageProps) => {
- + - +
- {/*
*/}
); }; diff --git a/app/(landing)/projects/[id]/page.tsx b/app/(landing)/projects/[id]/page.tsx index e1aee744d..f260ab0be 100644 --- a/app/(landing)/projects/[id]/page.tsx +++ b/app/(landing)/projects/[id]/page.tsx @@ -73,11 +73,11 @@ async function ProjectContent({ id }: { id: string }) { response.data.project, response.data.crowdfund ); + // Project data loaded successfully } else { throw new Error(response.message || 'Failed to fetch project'); } - } catch (err) { - console.error('Error fetching project:', err); + } catch { error = 'Failed to fetch project data'; } @@ -86,7 +86,7 @@ async function ProjectContent({ id }: { id: string }) { } return ( -
+
+
diff --git a/app/globals.css b/app/globals.css index ce48e5a7b..2fef9035b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -16,6 +16,45 @@ --color-gray-700: #787878; --color-gray-800: #484848; --color-gray-900: #2b2b2b; + + /* Warning Colors */ + --color-warning-50: #fef6e7; + --color-warning-75: #fbe2b7; + --color-warning-100: #f7d394; + --color-warning-200: #f7c164; + --color-warning-300: #f5b546; + --color-warning-400: #dd900d; + --color-warning-500: #dd900d; + --color-warning-600: #ad6f07; + --color-warning-700: #865503; + --color-warning-800: #664101; + --color-warning-900: #523300; + + /* Secondary Colors */ + --color-secondary-50: #e3effc; + --color-secondary-75: #c6ddf7; + --color-secondary-100: #b6d8ff; + --color-secondary-200: #80bbff; + --color-secondary-300: #3d89df; + --color-secondary-400: #1671d9; + --color-secondary-500: #0d5eba; + --color-secondary-600: #034592; + --color-secondary-700: #04326b; + --color-secondary-800: #012657; + --color-secondary-900: #001633; + + /* Success Colors */ + --color-success-50: #e7f6ec; + --color-success-75: #b5e3c4; + --color-success-100: #91d6a8; + --color-success-200: #5fc381; + --color-success-300: #40b869; + --color-success-400: #0f973d; + --color-success-500: #099137; + --color-success-600: #04802e; + --color-success-700: #036b26; + --color-success-800: #015b20; + --color-success-900: #004617; } /* Custom scrollbar styles for comment modal */ .custom-scrollbar { diff --git a/components/EmptyState.tsx b/components/EmptyState.tsx index bd33ff5c1..3a5350416 100644 --- a/components/EmptyState.tsx +++ b/components/EmptyState.tsx @@ -83,7 +83,7 @@ const EmptyState: React.FC = ({

{/* Button or custom action */} - {action ? ( + {action === false ? null : action ? ( action ) : (