- {project.name}
+ {project.title}
{project.description}
diff --git a/components/Projects.tsx b/components/Projects.tsx
deleted file mode 100644
index 0431b396..00000000
--- a/components/Projects.tsx
+++ /dev/null
@@ -1,407 +0,0 @@
-'use client';
-import React, { useState, useEffect, useCallback } from 'react';
-import { RecentProjectsProps } from '@/types/project';
-import { Plus, ChevronDown } from 'lucide-react';
-import ProjectCard from './project-card';
-import EmptyState from './EmptyState';
-import { BoundlessButton } from './buttons';
-import { motion } from 'framer-motion';
-import { fadeInUp, staggerContainer } from '@/lib/motion';
-import { Button } from './ui/button';
-import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from './ui/dropdown-menu';
-import Pagination from './ui/pagination';
-
-import { getProjects } from '@/lib/api/project';
-import { toast } from 'sonner';
-import { useAuth } from '@/hooks/use-auth';
-import { useProjectSheetStore } from '@/lib/stores/project-sheet-store';
-import { ProjectsSkeleton } from './skeleton/ProjectsSkeleton';
-
-// Strongly typed shape for the project objects returned by the API
-type ProjectApi = {
- _id: string;
- title: string;
- description: string;
- whitepaperUrl?: string;
- tags?: string[];
- category?: string;
- type?: string;
- status: string;
- createdAt: string;
- updatedAt: string;
- owner?: {
- type?: {
- _id?: string;
- profile?: {
- firstName?: string;
- lastName?: string;
- username?: string;
- avatar?: string;
- } | null;
- } | null;
- } | null;
-};
-
-type StatusFilter =
- | 'all'
- | 'idea'
- | 'under_review'
- | 'approved'
- | 'funding'
- | 'funded'
- | 'completed';
-type TabFilter = 'mine' | 'others';
-
-const Projects = () => {
- const [statusFilter, setStatusFilter] = useState('all');
- const [tabFilter, setTabFilter] = useState('mine');
- const [projects, setProjects] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [currentPage, setCurrentPage] = useState(1);
- const [totalPages, setTotalPages] = useState(1);
- const [totalItems, setTotalItems] = useState(0);
- const { user, isAuthenticated } = useAuth(false);
- const sheet = useProjectSheetStore();
-
- const ITEMS_PER_PAGE = 9;
-
- const filterOptions = [
- { value: 'all', label: 'All' },
- { value: 'idea', label: 'Ideas' },
- { value: 'under_review', label: 'Under Review' },
- { value: 'approved', label: 'Approved' },
- { value: 'funding', label: 'Funding' },
- { value: 'funded', label: 'Funded' },
- { value: 'completed', label: 'Completed' },
- ];
-
- const fetchProjects = useCallback(
- async (pageNum = 1) => {
- try {
- setLoading(true);
- setError(null);
-
- // Build filters based on current state
- const filters: { status?: string; owner?: string } = {};
-
- if (statusFilter !== 'all') {
- filters.status = statusFilter;
- }
-
- // Add owner filter for "mine" tab
- if (tabFilter === 'mine' && isAuthenticated && user) {
- filters.owner = user.id;
- }
-
- const response = await getProjects(pageNum, ITEMS_PER_PAGE, filters);
-
- const apiProjects = (response.projects ??
- []) as unknown as ProjectApi[];
- const transformedProjects: RecentProjectsProps[] = apiProjects.map(
- project => ({
- id: project._id,
- name: project.title,
- description: project.description,
- image: project.whitepaperUrl || '/banner.png',
- link: `/projects/${project._id}`,
- tags: project.tags || [],
- category: project.category ?? 'unknown',
- type: project.type ?? 'unknown',
- amount: 0,
- status: project.status,
- createdAt: project.createdAt,
- updatedAt: project.updatedAt,
- // Add owner information for filtering
- owner: project.owner?.type?._id || null,
- ownerName: project.owner?.type?.profile
- ? `${project.owner.type.profile.firstName} ${project.owner.type.profile.lastName}`
- : 'Anonymous',
- ownerUsername:
- project.owner?.type?.profile?.username || 'anonymous',
- ownerAvatar: project.owner?.type?.profile?.avatar || '',
- })
- );
-
- setProjects(transformedProjects);
-
- // Update pagination state
- if (response.pagination) {
- setTotalPages(response.pagination.totalPages || 1);
- setTotalItems(response.pagination.totalItems || 0);
- }
- } catch {
- setError('Failed to fetch projects');
- toast.error('Failed to fetch projects');
- } finally {
- setLoading(false);
- }
- },
- [isAuthenticated, statusFilter, tabFilter, user]
- );
-
- useEffect(() => {
- setCurrentPage(1);
- fetchProjects(1);
- }, [statusFilter, tabFilter, isAuthenticated, user?.id, fetchProjects]);
-
- const handlePageChange = (page: number) => {
- setCurrentPage(page);
- fetchProjects(page);
- };
-
- if (loading) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
- setTabFilter(value as TabFilter)}
- className='w-full sm:w-auto'
- >
-
-
- My Projects
-
-
- Explore ({totalItems})
-
-
-
-
-
- setStatusFilter(value as StatusFilter)}
- className='w-full sm:w-auto'
- >
-
-
- All
-
-
- Funding
-
-
- Funded
-
-
-
-
-
-
- {
- filterOptions.find(option => option.value === statusFilter)
- ?.label
- }{' '}
-
-
-
-
- {filterOptions.map(option => (
- setStatusFilter(option.value as StatusFilter)}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white hover:shadow-[0_1px_4px_0_rgba(40,45,40,0.04),_0_0_24px_1px_rgba(10,15,10,0.14)]'
- >
- {option.label}
-
- ))}
-
-
-
-
-
- {/* Authentication Notice */}
- {!isAuthenticated && (
-
-
- ๐ You're not signed in. Sign in to see your projects in the "Mine"
- tab.
-
-
- )}
-
- {/* Projects Grid */}
-
- {error ? (
-
-
-
-
{error}
-
-
-
-
- ) : (
- (() => {
- // Since filtering is now handled at the API level, we just use the projects as-is
- let filteredProjects = projects;
-
- // For the "explore" tab, we need to filter out user's own projects client-side
- // since the API doesn't support "not owner" filtering
- if (tabFilter === 'others' && isAuthenticated && user) {
- filteredProjects = projects.filter(
- project =>
- project.owner !== user?.id &&
- project.ownerUsername !== user?.email &&
- project.ownerName !==
- (user?.profile?.firstName && user?.profile?.lastName
- ? `${user.profile.firstName} ${user.profile.lastName}`
- : user?.profile?.firstName || user?.profile?.lastName)
- );
- }
-
- // Handle empty state for "mine" tab when not authenticated
- if (tabFilter === 'mine' && !isAuthenticated) {
- return (
-
-
-
-
-
- );
- }
-
- if (filteredProjects.length > 0) {
- return filteredProjects.map((project, index) => (
-
-
-
- ));
- } else {
- return (
-
-
- }
- iconPosition='right'
- onClick={() => {
- sheet.openInitialize();
- }}
- >
- New Project
-
- ) : undefined
- }
- />
-
-
- );
- }
- })()
- )}
-
-
- {/* Pagination */}
- {totalPages > 1 && (
-
-
-
- )}
-
- );
-};
-
-export default Projects;
diff --git a/components/auth/AuthGuard.tsx b/components/auth/AuthGuard.tsx
new file mode 100644
index 00000000..f924a22e
--- /dev/null
+++ b/components/auth/AuthGuard.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import { ReactNode } from 'react';
+import { useRequireAuthEnhanced } from '@/hooks/use-auth';
+
+interface AuthGuardProps {
+ children: ReactNode;
+ redirectTo?: string;
+ fallback?: ReactNode;
+}
+
+export function AuthGuard({
+ children,
+ redirectTo = '/auth?mode=signin',
+ fallback = (
+
+ ),
+}: AuthGuardProps) {
+ const { isPending, isAuthenticated } = useRequireAuthEnhanced(redirectTo);
+
+ if (isPending) {
+ return <>{fallback}>;
+ }
+
+ if (!isAuthenticated) {
+ return null; // Will redirect via useRequireAuthEnhanced
+ }
+
+ return <>{children}>;
+}
diff --git a/components/auth/README.md b/components/auth/README.md
new file mode 100644
index 00000000..14d0cbb3
--- /dev/null
+++ b/components/auth/README.md
@@ -0,0 +1,144 @@
+# Authentication Patterns
+
+This directory contains reusable authentication patterns for Better Auth integration with Next.js. These patterns provide different ways to handle authentication throughout your application.
+
+## Available Patterns
+
+### 1. Enhanced Hook (`useRequireAuthEnhanced`)
+
+A simplified hook that automatically redirects unauthenticated users.
+
+```typescript
+import { useRequireAuthEnhanced } from '@/hooks/use-auth'
+
+function MyComponent() {
+ const { session, isPending, isAuthenticated } = useRequireAuthEnhanced('/login')
+
+ if (isPending) return Loading...
+ if (!isAuthenticated) return null // Will redirect automatically
+
+ return Welcome {session.user.name}!
+}
+```
+
+### 2. Higher-Order Component (`withAuth`)
+
+Wraps components to require authentication.
+
+```typescript
+import { withAuth } from '@/components/auth'
+
+function Dashboard({ session }) {
+ return Welcome {session.user.name}
+}
+
+const ProtectedDashboard = withAuth(Dashboard, '/login')
+```
+
+### 3. Auth Guard Component (`AuthGuard`)
+
+A component wrapper that protects its children.
+
+```typescript
+import { AuthGuard } from '@/components/auth'
+
+function App() {
+ return (
+ } redirectTo="/login">
+
+
+ )
+}
+```
+
+### 4. Auth Context Provider (`AuthProvider`)
+
+Provides authentication state throughout the component tree.
+
+```typescript
+import { AuthProvider, useAuthContext } from '@/components/auth'
+
+function App() {
+ return (
+
+
+
+ )
+}
+
+function Profile() {
+ const { data: session, isPending } = useAuthContext()
+ // Use session data...
+}
+```
+
+### 5. Client Utilities (`requireAuthClient`)
+
+Programmatic authentication checks for actions.
+
+```typescript
+import {
+ requireAuthClient,
+ handleProtectedAction,
+} from '@/lib/auth/client-utils';
+
+async function deleteAccount() {
+ const session = await requireAuthClient();
+ if (!session) return;
+
+ // Proceed with deletion...
+}
+```
+
+## Integration with Existing Auth
+
+These patterns work alongside your existing `ProtectedRoute` component:
+
+- **Use `ProtectedRoute`** for complex requirements (roles, verification, etc.)
+- **Use these patterns** for simple authentication requirements
+- **Combine patterns** as needed for your specific use cases
+
+## Quick Start
+
+```typescript
+// In your layout or _app.tsx
+import { AuthProvider } from '@/components/auth'
+
+export default function RootLayout({ children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+// In components
+import { AuthGuard, withAuth } from '@/components/auth'
+import { useRequireAuthEnhanced } from '@/hooks/use-auth'
+```
+
+## Best Practices
+
+1. **Choose the right pattern** for your use case:
+ - Simple auth checks โ `useRequireAuthEnhanced`
+ - Component wrapping โ `withAuth` or `AuthGuard`
+ - Global state access โ `AuthProvider`
+ - Programmatic checks โ `requireAuthClient`
+
+2. **Use consistent redirect URLs** across your app
+
+3. **Combine with existing patterns** when needed
+
+4. **Test authentication flows** in development
+
+## Migration from Existing Code
+
+Your existing authentication patterns remain unchanged. These new patterns provide additional options:
+
+- `useAuth()` โ Still available for complex auth logic
+- `ProtectedRoute` โ Still available for advanced requirements
+- `useRequireAuthEnhanced` โ Simpler alternative for basic auth
+
+## Examples
+
+See `examples.tsx` for comprehensive usage examples showing all patterns working together.
diff --git a/components/auth/SignupForm.tsx b/components/auth/SignupForm.tsx
index eb54eff3..950807da 100644
--- a/components/auth/SignupForm.tsx
+++ b/components/auth/SignupForm.tsx
@@ -4,7 +4,7 @@ import { LockIcon, MailIcon, User } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
-import { useEffect, useState } from 'react';
+import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import z from 'zod';
@@ -19,7 +19,6 @@ import {
FormMessage,
} from '../ui/form';
import { Input } from '../ui/input';
-import OtpForm from './OtpForm';
import { authClient } from '@/lib/auth-client';
const formSchema = z.object({
@@ -51,8 +50,6 @@ const SignupForm = ({
lastMethod,
}: SignupFormProps) => {
const router = useRouter();
- const [step, setStep] = useState<'signup' | 'otp'>('signup');
- const [userData, setUserData] = useState<{ email: string } | null>(null);
const isGoogleLastUsed = lastMethod === 'google';
const form = useForm>({
@@ -90,9 +87,12 @@ const SignupForm = ({
// Loading state handled by form
},
onSuccess: () => {
- setUserData({ email: values.email });
- setStep('otp');
- toast.success('OTP sent to your email!');
+ toast.success(
+ 'Verification email sent! Please check your email to verify your account. You will be automatically logged in once verified.'
+ );
+ router.push(
+ '/auth/check-email?email=' + encodeURIComponent(values.email)
+ );
},
onError: ctx => {
if (ctx.error.status === 409 || ctx.error.code === 'CONFLICT') {
@@ -133,40 +133,6 @@ const SignupForm = ({
}
};
- const handleOtpSuccess = () => {
- router.push('/auth?mode=signin');
- };
-
- const handleResendOtp = async () => {
- if (!userData) return;
-
- try {
- const { data, error } = await authClient.emailOtp.sendVerificationOtp({
- email: userData.email,
- type: 'email-verification',
- });
-
- if (data) {
- toast.success('OTP resent successfully!');
- } else if (error) {
- toast.error(error.message || 'Failed to resend OTP');
- }
- } catch {
- toast.error('Failed to resend OTP');
- }
- };
-
- if (step === 'otp' && userData) {
- return (
-
- );
- }
-
return (
<>
diff --git a/components/auth/index.ts b/components/auth/index.ts
new file mode 100644
index 00000000..c7b16301
--- /dev/null
+++ b/components/auth/index.ts
@@ -0,0 +1,13 @@
+// Export all authentication components and utilities
+export { withAuth } from './withAuth';
+export { AuthGuard } from './AuthGuard';
+export { AuthProvider, useAuthContext } from '../providers/AuthProvider';
+
+// Re-export existing components for convenience
+export {
+ ProtectedRoute,
+ RequireAuth,
+ RequireVerified,
+ RequireAdmin,
+ RequireUser,
+} from './protected-route';
diff --git a/components/auth/withAuth.tsx b/components/auth/withAuth.tsx
new file mode 100644
index 00000000..139d577e
--- /dev/null
+++ b/components/auth/withAuth.tsx
@@ -0,0 +1,28 @@
+'use client';
+
+import { ComponentType } from 'react';
+import { useRequireAuthEnhanced } from '@/hooks/use-auth';
+
+export function withAuth
(
+ Component: ComponentType,
+ redirectTo = '/auth?mode=signin'
+) {
+ return function AuthenticatedComponent(props: T) {
+ const { session, isPending, isAuthenticated } =
+ useRequireAuthEnhanced(redirectTo);
+
+ if (isPending) {
+ return (
+
+ );
+ }
+
+ if (!isAuthenticated) {
+ return null; // Will redirect via useRequireAuthEnhanced
+ }
+
+ return ;
+ };
+}
diff --git a/components/bounties/BountyComments.tsx b/components/bounties/BountyComments.tsx
new file mode 100644
index 00000000..9907b171
--- /dev/null
+++ b/components/bounties/BountyComments.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import { useAuth } from '@/hooks/use-auth';
+import { GenericCommentThread } from '@/components/comments/GenericCommentThread';
+import { useCommentSystem } from '@/hooks/use-comment-system';
+import { CommentEntityType } from '@/types/comment';
+
+interface BountyCommentsProps {
+ bountyId: string;
+}
+
+export function BountyComments({ bountyId }: BountyCommentsProps) {
+ const { user } = useAuth();
+
+ // Initialize the comment system for this bounty
+ const commentSystem = useCommentSystem({
+ entityType: CommentEntityType.BOUNTY,
+ entityId: bountyId,
+ page: 1,
+ limit: 20,
+ enabled: true,
+ });
+
+ // Current user info for the comment system
+ const currentUser = user
+ ? {
+ id: user.id,
+ name: user.name || user.email || 'Anonymous',
+ username: user.profile?.username || undefined,
+ image: user.image || undefined,
+ isModerator: user.role === 'ADMIN',
+ }
+ : {
+ id: 'anonymous',
+ name: 'Anonymous',
+ username: undefined,
+ image: undefined,
+ isModerator: false,
+ };
+
+ return (
+
+
+
Bounty Discussion
+
+ Discuss requirements, ask questions, and collaborate on this bounty
+
+
+
+
+
+ );
+}
diff --git a/components/campaigns/CampaignSummary.tsx b/components/campaigns/CampaignSummary.tsx
deleted file mode 100644
index 7467d1a6..00000000
--- a/components/campaigns/CampaignSummary.tsx
+++ /dev/null
@@ -1,297 +0,0 @@
-import React, { useState } from 'react';
-import BoundlessSheet from '../sheet/boundless-sheet';
-import Image from 'next/image';
-import { Badge } from '../ui/badge';
-import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
-import Link from 'next/link';
-import { Progress } from '../ui/progress';
-import { BoundlessButton } from '../buttons';
-import {
- CalendarIcon,
- Clock,
- ChevronDown,
- ChevronRight,
- MessageCircleMore,
- ThumbsUp,
- Users,
- Wallet,
- Check,
-} from 'lucide-react';
-import { ScrollArea } from '../ui/scroll-area';
-import { formatDate } from '@/lib/utils';
-import { Button } from '../ui/button';
-import BackingHistory from './backing-history';
-import { sampleBackers } from '@/lib/data/backing-history-mock';
-import { backingHistory, milestones } from '@/lib/data/milestones';
-
-const CampaignSummary = ({
- open,
- setOpen,
-}: {
- open: boolean;
- setOpen: (open: boolean) => void;
-}) => {
- const [expandedMilestone, setExpandedMilestone] = useState(
- null
- );
- const [openHistory, setOpenHistory] = useState(false);
-
- const toggle = (id: string) => {
- setExpandedMilestone(expandedMilestone === id ? null : id);
- };
-
- const calculateFundAmount = (percentage: number) => {
- return (123000 * percentage) / 100;
- };
-
- // Mock backing history data
-
- return (
- <>
-
-
-
-
-
-
-
-
-
-
-
Boundless
- Successful
-
-
- #web3
- #crowdfunding
-
-
-
-
-
- CN
-
-
-
- Collins Odumeje
-
-
-
-
- This campaign successfully reached its funding goal.
- Contributions are now being distributed through escrow as
- milestones are completed.{' '}
-
- Click to track milestone progress
-
-
-
-
-
-
-
Raised
-
- $123,000.00
-
-
-
-
Target
-
- $123,000.00
-
-
-
-
-
-
-
-
- 100k
-
-
-
- 100k
-
-
-
-
-
- 100 backers
-
-
-
-
- 100 days left
-
-
-
-
-
-
-
- Campaign Details
-
-
-
- Boundless is a trustless, decentralized application (dApp)
- that empowers changemakers and builders to raise funds
- transparently without intermediaries. Campaigns are structured
- around clearly defined milestones, with funds held in escrow
- and released only upon approval. Grant creators can launch
- programs with rule-based logic, and applicants can apply with
- proposals that go through public validation. The platform is
- built on the Stellar blockchain and powered by Soroban smart
- contracts to ensure transparency, security, and autonomy.
-
-
-
-
-
Milestones
-
- {milestones.map((milestone, idx) => {
- const isExpanded = expandedMilestone === milestone.id;
- return (
-
-
- Milestone {idx + 1}
-
-
-
toggle(milestone.id)}
- >
-
- {milestone.title || `Milestone ${idx + 1}`}
-
-
-
- {isExpanded && (
-
-
- {milestone.description}
-
-
-
-
-
- {formatDate(milestone.deliveryDate)}
-
-
-
-
- $
- {calculateFundAmount(
- milestone.fundPercentage
- ).toLocaleString()}{' '}
- ({milestone.fundPercentage || 0}%)
-
-
-
-
- )}
-
-
- );
- })}
-
-
-
-
-
- Backing History
-
-
-
-
- {backingHistory.map(backer => (
-
-
-
-
-
-
- {backer.name.charAt(0)}
-
-
- {backer.isVerified && (
-
-
-
- )}
-
-
-
- {backer.name}
-
-
-
- {backer.wallet}
-
-
-
-
-
- ${backer.amount.toLocaleString()}
-
-
-
-
- ))}
-
-
-
-
-
-
- >
- );
-};
-
-export default CampaignSummary;
diff --git a/components/campaigns/CampaignTable.tsx b/components/campaigns/CampaignTable.tsx
deleted file mode 100644
index 1ecedc31..00000000
--- a/components/campaigns/CampaignTable.tsx
+++ /dev/null
@@ -1,687 +0,0 @@
-import React, { useState, useEffect, useCallback } from 'react';
-import { Button } from '../ui/button';
-import { MoreVerticalIcon, CheckIcon, ChevronDown } from 'lucide-react';
-import { Tabs, TabsList, TabsTrigger } from '../ui/tabs';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '../ui/dropdown-menu';
-import { BoundlessButton } from '../buttons';
-import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
-import { Badge } from '../ui/badge';
-import Image from 'next/image';
-import { toast } from 'sonner';
-import CampaignSummary from './CampaignSummary';
-import {
- Campaign,
- StatusFilter,
- TabFilter,
- mockApiService,
-} from '@/lib/data/campaigns-mock';
-import BackingHistory from './backing-history';
-import { sampleBackers } from '@/lib/data/backing-history-mock';
-import { CampaignTableSkeleton } from '../skeleton/UserPageSkeleton';
-import Pagination from '../ui/pagination';
-
-const CampaignRow = ({
- campaign,
- onAction,
-}: {
- campaign: Campaign;
- onAction: (action: string, campaignId: string) => Promise;
-}) => {
- const getStatusColor = (status: string) => {
- switch (status) {
- case 'live':
- return 'bg-[#1671D9] text-white';
- case 'successful':
- return 'bg-primary text-background';
- case 'failed':
- return 'bg-[#D42620] text-white';
- default:
- return 'bg-gray-500 text-white';
- }
- };
-
- const getProgressColor = () => {
- // Use the same blue color for all progress bars
- return 'bg-[#1671D9]';
- };
-
- const progressPercentage =
- (campaign.fundingProgress.current / campaign.fundingProgress.target) * 100;
-
- const handleAction = (action: string) => {
- onAction(action, campaign.id);
- };
-
- return (
- <>
-
-
-
-
-
-
-
- {campaign.name}
-
-
- {campaign.tags.join(' ')}
-
-
-
-
-
-
-
-
-
- {campaign.creator.name
- .split(' ')
- .map(n => n[0])
- .join('')}
-
-
- {campaign.creator.verified && (
-
-
-
- )}
-
-
- {campaign.creator.name}
-
-
-
-
-
- ${campaign.fundingProgress.current.toLocaleString()} /
-
- ${campaign.fundingProgress.target.toLocaleString()}
-
-
-
-
-
-
- {campaign.endDate}
-
-
-
- {campaign.milestones}
-
-
-
-
- {campaign.status}
-
-
-
-
-
-
-
-
-
- {campaign.status === 'live' && (
- <>
- handleAction('back-project')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white hover:shadow-[0_1px_4px_0_rgba(40,45,40,0.04),_0_0_24px_1px_rgba(10,15,10,0.14)]'
- >
- Back Project
-
- handleAction('share')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white hover:shadow-[0_1px_4px_0_rgba(40,45,40,0.04),_0_0_24px_1px_rgba(10,15,10,0.14)]'
- >
- Share
-
- handleAction('view-history')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white hover:shadow-[0_1px_4px_0_rgba(40,45,40,0.04),_0_0_24px_1px_rgba(10,15,10,0.14)]'
- >
- View History
-
- handleAction('like')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white hover:shadow-[0_1px_4px_0_rgba(40,45,40,0.04),_0_0_24px_1px_rgba(10,15,10,0.14)]'
- >
- Like ({campaign.likes})
-
- handleAction('comment')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white hover:shadow-[0_1px_4px_0_rgba(40,45,40,0.04),_0_0_24px_1px_rgba(10,15,10,0.14)]'
- >
- Comment ({campaign.comments})
-
- handleAction('campaign-details')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white hover:shadow-[0_1px_4px_0_rgba(40,45,40,0.04),_0_0_24px_1px_rgba(10,15,10,0.14)]'
- >
- Campaign Details
-
- >
- )}
- {campaign.status === 'successful' && (
- <>
- handleAction('view-summary')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white hover:shadow-[0_1px_4px_0_rgba(40,45,40,0.04),_0_0_24px_1px_rgba(10,15,10,0.14)]'
- >
- View Summary
-
- handleAction('like')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white hover:shadow-[0_1px_4px_0_rgba(40,45,40,0.04),_0_0_24px_1px_rgba(10,15,10,0.14)]'
- >
- Like ({campaign.likes})
-
- handleAction('comment')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white hover:shadow-[0_1px_4px_0_rgba(40,45,40,0.04),_0_0_24px_1px_rgba(10,15,10,0.14)]'
- >
- Comment ({campaign.comments})
-
- >
- )}
- {campaign.status === 'failed' && (
- <>
- handleAction('view-summary')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white hover:shadow-[0_1px_4px_0_rgba(40,45,40,0.04),_0_0_24px_1px_rgba(10,15,10,0.14)]'
- >
- View Summary
-
- handleAction('like')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white hover:shadow-[0_1px_4px_0_rgba(40,45,40,0.04),_0_0_24px_1px_rgba(10,15,10,0.14)]'
- >
- Like ({campaign.likes})
-
- handleAction('comment')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white hover:shadow-[0_1px_4px_0_rgba(40,45,40,0.04),_0_0_24px_1px_rgba(10,15,10,0.14)]'
- >
- Comment ({campaign.comments})
-
- >
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {campaign.name}
-
-
- {campaign.tags.join(' ')}
-
-
-
-
-
- {campaign.status}
-
-
-
-
-
-
- {campaign.status === 'live' && (
- <>
- handleAction('share')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white'
- >
- Share
-
- handleAction('view-history')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white'
- >
- View History
-
- handleAction('like')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white'
- >
- Like ({campaign.likes})
-
- handleAction('comment')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white'
- >
- Comment ({campaign.comments})
-
- handleAction('campaign-details')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white'
- >
- Campaign Details
-
- >
- )}
- {campaign.status === 'successful' && (
- <>
- handleAction('view-summary')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white'
- >
- View Summary
-
- handleAction('like')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white'
- >
- Like ({campaign.likes})
-
- handleAction('comment')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white'
- >
- Comment ({campaign.comments})
-
- >
- )}
- {campaign.status === 'failed' && (
- <>
- handleAction('view-summary')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white'
- >
- View Summary
-
- handleAction('like')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white'
- >
- Like ({campaign.likes})
-
- handleAction('comment')}
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white'
- >
- Comment ({campaign.comments})
-
- >
- )}
-
-
-
-
-
-
-
-
-
-
-
- {campaign.creator.name
- .split(' ')
- .map(n => n[0])
- .join('')}
-
-
- {campaign.creator.verified && (
-
-
-
- )}
-
-
- {campaign.creator.name}
-
-
-
-
-
- ${campaign.fundingProgress.current.toLocaleString()} / $
- {campaign.fundingProgress.target.toLocaleString()}
-
-
-
-
-
-
- End Date: {campaign.endDate}
-
-
- Milestones:{' '}
- {campaign.milestones}
-
-
-
-
- >
- );
-};
-
-interface CampaignTableProps {
- limit?: number;
- showPagination?: boolean;
-}
-
-const CampaignTable = ({
- limit = 100,
- showPagination = false,
-}: CampaignTableProps) => {
- const [statusFilter, setStatusFilter] = useState('all');
- const [tabFilter, setTabFilter] = useState('mine');
- const [campaigns, setCampaigns] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [campaignSummaryOpen, setCampaignSummaryOpen] = useState(false);
- const [backingHistoryOpen, setBackingHistoryOpen] = useState(false);
- const [currentPage, setCurrentPage] = useState(1);
- const [totalPages, setTotalPages] = useState(1);
- const itemsPerPage = limit;
-
- // Quick filter options - keeping it simple for now
- const filterOptions = [
- { value: 'all', label: 'All' },
- { value: 'live', label: 'Live' },
- { value: 'successful', label: 'Successful' },
- { value: 'failed', label: 'Failed' },
- ];
-
- const fetchCampaigns = useCallback(
- async (page: number = 1) => {
- try {
- setLoading(true);
- setError(null);
- const response = await mockApiService.fetchCampaigns(
- statusFilter,
- tabFilter,
- page,
- itemsPerPage
- );
- setCampaigns(response.data);
- if (showPagination) {
- setTotalPages(Math.ceil(response.total / itemsPerPage));
- }
- } catch {
- setError('Failed to fetch campaigns');
- toast.error('Failed to fetch campaigns');
- } finally {
- setLoading(false);
- }
- },
- [statusFilter, tabFilter, itemsPerPage, showPagination]
- );
-
- // Handle different campaign actions - TODO: extract this to a separate hook
- const handleCampaignAction = async (action: string, campaignId: string) => {
- try {
- switch (action) {
- case 'like':
- await mockApiService.likeCampaign(campaignId);
- toast.success('Liked!');
- break;
- case 'comment':
- // TODO: Open comment modal instead of hardcoded comment
- await mockApiService.commentCampaign(campaignId, 'Great campaign!');
- toast.success('Comment added');
- break;
- case 'share':
- await mockApiService.shareCampaign(campaignId);
- toast.success('Shared!');
- break;
- case 'view-summary':
- setCampaignSummaryOpen(true);
- break;
- case 'view-history':
- toast.info('Opening history...');
- setBackingHistoryOpen(true);
- break;
- case 'campaign-details':
- // TODO: Navigate to details page
- toast.info('Opening details...');
- break;
- default:
- toast.info(`${action} clicked`);
- }
-
- // Refresh the list after actions
- fetchCampaigns();
- } catch {
- toast.error('Something went wrong');
- }
- };
-
- useEffect(() => {
- setCurrentPage(1);
- fetchCampaigns(1);
- }, [statusFilter, tabFilter, fetchCampaigns]);
-
- useEffect(() => {
- if (showPagination) {
- fetchCampaigns(currentPage);
- }
- }, [currentPage, fetchCampaigns, showPagination]);
-
- const handlePageChange = (page: number) => {
- setCurrentPage(page);
- window.scrollTo({ top: 0, behavior: 'smooth' });
- };
-
- if (loading) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
-
- Campaigns
-
- {/*
-
- */}
-
-
- setTabFilter(value as TabFilter)}
- className='w-full sm:w-auto'
- >
-
-
- Mine
-
-
- Others
-
-
-
-
-
-
- {
- filterOptions.find(option => option.value === statusFilter)
- ?.label
- }{' '}
-
-
-
-
- {filterOptions.map(option => (
-
- setStatusFilter(
- option.value as 'all' | 'live' | 'successful' | 'failed'
- )
- }
- className='cursor-pointer rounded-md px-3 py-2 text-sm font-medium text-white transition-colors duration-200 hover:!bg-[#2B2B2B] hover:!text-white hover:shadow-[0_1px_4px_0_rgba(40,45,40,0.04),_0_0_24px_1px_rgba(10,15,10,0.14)]'
- >
- {option.label}
-
- ))}
-
-
-
-
-
-
-
Campaign Name
-
Creator
-
Funding Progress
-
End Date
-
Milestones
-
Status
-
Actions
-
-
-
- {error ? (
-
-
-
{error}
-
-
-
- ) : campaigns.length === 0 ? (
-
-
-
-
-
-
- No Active Campaigns
-
-
- Projects you vote on, comment on, or fund will appear here. Get
- involved and support ideas that matter to you.
-
-
-
- ) : (
- campaigns.map(campaign => (
-
- ))
- )}
-
- {showPagination && campaigns.length > 0 && (
-
- )}
-
-
-
-
-
- );
-};
-
-export default CampaignTable;
diff --git a/components/campaigns/LaunchCampaignFlow.tsx b/components/campaigns/LaunchCampaignFlow.tsx
deleted file mode 100644
index de7b96c9..00000000
--- a/components/campaigns/LaunchCampaignFlow.tsx
+++ /dev/null
@@ -1,779 +0,0 @@
-'use client';
-
-import React, { useState } from 'react';
-
-import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
-import { Badge } from '@/components/ui/badge';
-import {
- Calendar,
- X,
- Package,
- DollarSign,
- ImagePlus,
- ArrowLeft,
-} from 'lucide-react';
-import Image from 'next/image';
-import { cn } from '@/lib/utils';
-import {
- Select,
- SelectItem,
- SelectContent,
- SelectValue,
- SelectTrigger,
-} from '@/components/ui/select';
-import { BoundlessButton } from '../buttons';
-import { Checkbox } from '../ui/checkbox';
-import { useWalletProtection } from '@/hooks/use-wallet-protection';
-import WalletRequiredModal from '@/components/wallet/WalletRequiredModal';
-
-interface LaunchCampaignFlowProps {
- onComplete: () => void;
-}
-
-interface CampaignFormData {
- title: string;
- description: string;
- fundingGoal: string;
- category: string;
- images: string[];
- duration: string;
-}
-
-interface EscrowData {
- network: string;
- transactionType: string;
- walletAddress: string;
- agreedToTerms: boolean;
-}
-
-interface Milestone {
- id: string;
- title: string;
- description: string;
- deliveryDate: string;
- fundAmount: number;
- isExpanded: boolean;
-}
-
-const LaunchCampaignFlow: React.FC = ({
- onComplete,
-}) => {
- const [currentPhase, setCurrentPhase] = useState<'details' | 'escrow'>(
- 'details'
- );
-
- // Wallet protection hook
- const {
- requireWallet,
- showWalletModal,
- handleWalletConnected,
- closeWalletModal,
- } = useWalletProtection({
- actionName: 'launch campaign',
- });
- const [formData, setFormData] = useState({
- title: 'Boundless',
- description:
- 'Boundless is a trustless, decentralized application (dApp) that empowers changemakers and builders to raise funds transparently without intermediaries. Campaigns are structured around clearly defined milestones, with funds held in escrow and released only upon approval. Grant creators can create campaigns with defined milestones and funding goals.',
- fundingGoal: '123,000.00',
- category: '',
- images: [] as string[],
- duration: '90 Days',
- });
- const [selectedTags, setSelectedTags] = useState([
- 'Web3',
- 'Crowdfunding',
- ]);
- const [escrowData, setEscrowData] = useState({
- network: 'Stella / Soroban',
- transactionType: 'On-chain, irreversible',
- walletAddress: '',
- agreedToTerms: false,
- });
-
- const milestones = [
- {
- id: '1',
- title: 'Prototype & Smart Contract Setup',
- description:
- 'Develop a functional UI prototype for the crowdfunding and grant flow. Simultaneously, implement and test Soroban smart contracts for escrow logic, milestone validation, and secure fund handling.',
- deliveryDate: 'October 10, 2025',
- fundAmount: 29000,
- isExpanded: true,
- },
- {
- id: '2',
- title: 'Campaign & Grant Builder Integration',
- description:
- 'Integrate campaign creation tools and grant builder functionality.',
- deliveryDate: 'November 15, 2025',
- fundAmount: 43050,
- isExpanded: false,
- },
- {
- id: '3',
- title: 'Platform Launch & Community Building',
- description:
- 'Launch the platform to the public and build a strong community.',
- deliveryDate: 'December 20, 2025',
- fundAmount: 49200,
- isExpanded: false,
- },
- ];
-
- const escrowTerms = [
- {
- title: 'Smart Contract Control',
- description:
- 'All funds contributed to your campaign will be held in a smart contract powered by Soroban.',
- },
- {
- title: 'Milestone-Based Release',
- description:
- 'Funds will only be released upon successful completion and approval of individual milestones as defined in your campaign.',
- },
- {
- title: 'Immutable Fund Allocation',
- description:
- 'Once the campaign is submitted to escrow, your milestone structure and fund percentages cannot be modified.',
- },
- {
- title: 'Non-custodial Holding',
- description:
- 'Boundless does not hold your funds. Escrow is fully decentralized and governed by the smart contract.',
- },
- {
- title: 'Unmet Funding Goal',
- description:
- 'If the campaign goal is not reached by the deadline, no funds will be released and contributors may be refunded (depending on platform policy).',
- },
- {
- title: 'KYC Compliance',
- description:
- 'You must maintain a verified KYC status throughout the campaign to remain eligible for fund disbursement.',
- },
- {
- title: 'Transparent',
- description:
- 'All fund flows and milestone reviews are publicly visible for accountability.',
- },
- ];
-
- const handleInputChange = (field: string, value: string) => {
- setFormData(prev => ({ ...prev, [field]: value }));
- };
-
- const handleTagToggle = (tag: string) => {
- setSelectedTags(prev =>
- prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
- );
- };
-
- const handleImageUpload = (event: React.ChangeEvent) => {
- const files = event.target.files;
- if (files && files.length > 0) {
- const validFiles = Array.from(files).filter(
- file => file.type.startsWith('image/') && file.size <= 5 * 1024 * 1024 // 5MB limit
- );
-
- if (validFiles.length !== files.length) {
- // console.warn(
- // 'Some files were skipped - only images under 5MB are allowed'
- // );
- }
-
- const newImages = validFiles.map(file => URL.createObjectURL(file));
- setFormData(prev => ({
- ...prev,
- images: [...prev.images, ...newImages].slice(0, 4), // Limit to 4 images
- }));
- }
- };
-
- const removeImage = (index: number) => {
- setFormData(prev => ({
- ...prev,
- images: prev.images.filter((_, i) => i !== index),
- }));
- };
-
- const isFormValid = (): boolean => {
- return Boolean(
- formData.title &&
- formData.description &&
- formData.fundingGoal &&
- formData.images.length > 0 &&
- formData.duration
- );
- };
-
- const isEscrowValid = (): boolean => {
- return Boolean(escrowData.walletAddress && escrowData.agreedToTerms);
- };
-
- const handleNextPhase = () => {
- if (currentPhase === 'details') {
- setCurrentPhase('escrow');
- }
- };
-
- const handleBackPhase = () => {
- if (currentPhase === 'escrow') {
- setCurrentPhase('details');
- }
- };
-
- const phases = [
- {
- title: 'Campaign Details',
- state:
- currentPhase === 'details'
- ? ('active' as const)
- : ('completed' as const),
- },
- {
- title: 'Escrow Setup',
- state:
- currentPhase === 'escrow' ? ('active' as const) : ('pending' as const),
- },
- ];
-
- return (
-
- {/* Header with Progress Steps */}
-
-
- {currentPhase === 'details' ? 'Set Campaign Details' : 'Escrow Setup'}
-
-
-
- {phases.map((phase, index) => (
-
- ))}
-
-
-
-
- {currentPhase === 'details' ? (
-
- ) : (
-
requireWallet(onComplete)}
- />
- )}
-
- {/* Wallet Required Modal */}
-
-
- );
-};
-
-// Campaign Details Form Component
-const CampaignDetailsForm: React.FC<{
- formData: CampaignFormData;
- selectedTags: string[];
- onInputChange: (field: string, value: string) => void;
- onTagToggle: (tag: string) => void;
- onImageUpload: (event: React.ChangeEvent) => void;
- onRemoveImage: (index: number) => void;
- isFormValid: () => boolean;
- onNext: () => void;
-}> = ({
- formData,
- selectedTags,
- onInputChange,
- onTagToggle,
- onImageUpload,
- onRemoveImage,
- isFormValid,
- onNext,
-}) => {
- const [tagQuery, setTagQuery] = useState('');
- const [isSuggestionsOpen, setIsSuggestionsOpen] = useState(false);
-
- const projectTags = [
- { value: 'web3', label: 'Web3' },
- { value: 'crowdfunding', label: 'Crowdfunding' },
- { value: 'defi', label: 'DeFi' },
- { value: 'nft', label: 'NFT' },
- { value: 'dao', label: 'DAO' },
- { value: 'blockchain', label: 'Blockchain' },
- { value: 'cryptocurrency', label: 'Cryptocurrency' },
- { value: 'smart-contracts', label: 'Smart Contracts' },
- { value: 'dapp', label: 'dApp' },
- { value: 'metaverse', label: 'Metaverse' },
- { value: 'gaming', label: 'Gaming' },
- { value: 'social-impact', label: 'Social Impact' },
- { value: 'sustainability', label: 'Sustainability' },
- { value: 'education', label: 'Education' },
- { value: 'healthcare', label: 'Healthcare' },
- { value: 'finance', label: 'Finance' },
- { value: 'art', label: 'Art' },
- { value: 'music', label: 'Music' },
- { value: 'sports', label: 'Sports' },
- { value: 'technology', label: 'Technology' },
- ];
-
- const handleAddTag = (tag: string) => {
- const trimmedTag = tag.trim();
- if (trimmedTag && !selectedTags.includes(trimmedTag)) {
- onTagToggle(trimmedTag);
- }
- setTagQuery('');
- setIsSuggestionsOpen(false);
- };
-
- const handleRemoveTag = (tag: string) => {
- onTagToggle(tag);
- };
- return (
-
- {/* Campaign Title */}
-
-
-
-
-
onInputChange('title', e.target.value)}
- type='text'
- className='text-placeholder w-full bg-transparent text-base font-normal focus:outline-none'
- placeholder='Enter campaign title'
- />
-
-
-
-
-
-
-
-
-
-
-
- onInputChange('fundingGoal', e.target.value)}
- type='number'
- className='text-placeholder w-full !border-none bg-transparent text-base font-normal focus:outline-none'
- placeholder='Enter the amount you need to fund this campaign'
- disabled
- />
-
-
-
-
-
-
-
- {selectedTags.map(tag => (
-
- {projectTags.find(t => t.value === tag)?.label || tag}
-
-
- ))}
- {
- setTagQuery(e.target.value);
- setIsSuggestionsOpen(true);
- }}
- onFocus={() => setIsSuggestionsOpen(true)}
- onBlur={() => {
- // Delay closing to allow click on suggestion
- setTimeout(() => setIsSuggestionsOpen(false), 120);
- }}
- onKeyDown={e => {
- if (e.key === 'Enter' || e.key === ',') {
- e.preventDefault();
- handleAddTag(tagQuery);
- } else if (
- e.key === 'Backspace' &&
- tagQuery === '' &&
- selectedTags.length > 0
- ) {
- handleRemoveTag(selectedTags[selectedTags.length - 1]);
- }
- }}
- className='text-placeholder placeholder:text-placeholder/60 min-w-[140px] flex-1 bg-transparent text-base font-normal focus:outline-none'
- placeholder='Type and press Enter'
- aria-label='Add tag'
- />
-
-
- {/* Suggestions dropdown */}
- {isSuggestionsOpen && tagQuery.trim().length > 0 && (
-
-
- {projectTags
- .filter(
- t =>
- !selectedTags.includes(t.value) &&
- (t.label.toLowerCase().includes(tagQuery.toLowerCase()) ||
- t.value.toLowerCase().includes(tagQuery.toLowerCase()))
- )
- .map(t => (
- -
-
-
- ))}
- {/* Create custom tag */}
- {!projectTags.some(
- t =>
- t.value.toLowerCase() === tagQuery.toLowerCase() ||
- t.label.toLowerCase() === tagQuery.toLowerCase()
- ) &&
- !selectedTags.includes(tagQuery.trim()) && (
- -
-
-
- )}
-
-
- )}
-
-
-
- {/* Upload Campaign Images */}
-
-
-
- {formData.images.map((image: string, index: number) => (
-
-
-
-
-
-
- ))}
- {formData.images.length < 4 && (
-
- )}
-
-
-
- {/* Campaign Duration */}
-
-
-
-
-
- {/* Action Buttons */}
-
- {/* */}
-
- Review & Submit
-
-
-
- );
-};
-
-// Escrow Setup Form Component
-const EscrowSetupForm: React.FC<{
- escrowData: EscrowData;
- setEscrowData: (data: EscrowData) => void;
- milestones: Milestone[];
- escrowTerms: { title: string; description: string }[];
- isEscrowValid: () => boolean;
- onBack: () => void;
- onComplete: () => void;
-}> = ({
- escrowData,
- setEscrowData,
- milestones,
- escrowTerms,
- isEscrowValid,
- onBack,
- onComplete,
-}) => {
- const [expandedMilestones, setExpandedMilestones] = useState([0]);
-
- const toggleMilestone = (index: number) => {
- setExpandedMilestones(prev =>
- prev.includes(index) ? prev.filter(i => i !== index) : [...prev, index]
- );
- };
-
- return (
-
- {/* Milestone Summary */}
-
-
Milestone Summary
-
- {milestones.map((milestone, index) => (
-
-
-
- {expandedMilestones.includes(index) && (
-
-
- {milestone.description}
-
-
-
-
- {milestone.deliveryDate}
-
-
- $
- ${milestone.fundAmount.toLocaleString()}.00
-
-
-
- )}
-
- ))}
-
-
-
- {/* Escrow Terms */}
-
-
Escrow Terms
-
- {escrowTerms.map((term, index) => (
-
-
{term.title}
-
{term.description}
-
- ))}
-
-
-
- {/* Agreement */}
-
-
-
- setEscrowData({
- ...escrowData,
- agreedToTerms: !escrowData.agreedToTerms,
- })
- }
- />
-
-
-
-
- {/* Action Buttons */}
-
-
- Back
-
-
- Submit
-
-
-
- );
-};
-
-export default LaunchCampaignFlow;
diff --git a/components/campaigns/back-project/back-project-form.tsx b/components/campaigns/back-project/back-project-form.tsx
deleted file mode 100644
index 3a13f04a..00000000
--- a/components/campaigns/back-project/back-project-form.tsx
+++ /dev/null
@@ -1,201 +0,0 @@
-'use client';
-
-import type React from 'react';
-import { useState } from 'react';
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select';
-import { Checkbox } from '@/components/ui/checkbox';
-import { ArrowLeft, Check, Copy } from 'lucide-react';
-import { BoundlessButton } from '@/components/buttons';
-
-interface BackProjectFormProps {
- onSubmit: (data: {
- amount: string;
- currency: string;
- token: string;
- network: string;
- walletAddress: string;
- keepAnonymous: boolean;
- }) => void;
- isLoading?: boolean;
-}
-
-const QUICK_AMOUNTS = [10, 20, 30, 50, 100, 500, 1000];
-
-export function BackProjectForm({
- onSubmit,
- isLoading = false,
-}: BackProjectFormProps) {
- const [amount, setAmount] = useState('');
- const [currency] = useState('USDT');
- const [token, setToken] = useState('');
- const [network, setNetwork] = useState('Stella / Soroban');
- const [walletAddress] = useState('GDS3...GB7');
- const [keepAnonymous, setKeepAnonymous] = useState(false);
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- onSubmit({
- amount,
- currency,
- token,
- network,
- walletAddress,
- keepAnonymous,
- });
- };
-
- const handleQuickAmount = (quickAmount: number) => {
- setAmount(quickAmount.toString());
- };
-
- const handleCopyAddress = async (e: React.MouseEvent) => {
- e.preventDefault();
- try {
- await navigator.clipboard.writeText(walletAddress);
- // Could add toast notification here instead of state
- } catch {
- // Fallback for browsers that don't support clipboard API
- const textArea = document.createElement('textarea');
- // Silent fallback for older browsers
- textArea.value = walletAddress;
- document.body.appendChild(textArea);
- textArea.select();
- try {
- document.execCommand('copy');
- } catch {
- // Copy failed, but no need to log in production
- }
- document.body.removeChild(textArea);
- }
- };
-
- const isFormValid = amount && currency && token && walletAddress;
-
- return (
-
-
-
-
-
- Funds will be held in escrow and released only upon milestone
- approvals.
-
-
-
-
-
-
- {currency}
- setAmount(e.target.value)}
- type='number'
- className='text-placeholder w-full bg-transparent text-base font-normal focus:outline-none'
- placeholder='1000'
- disabled={isLoading}
- />
-
-
min. amount: $10
-
-
- {QUICK_AMOUNTS.map(quickAmount => (
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
- setNetwork(e.target.value)}
- type='text'
- className='text-placeholder w-full bg-transparent text-base font-normal focus:outline-none'
- disabled={isLoading}
- />
-
-
-
-
-
-
-
- {walletAddress}
-
-
-
-
-
- setKeepAnonymous(checked as boolean)}
- disabled={isLoading}
- className='border-stepper-border data-[state=checked]:bg-primary data-[state=checked]:border-primary'
- />
-
-
-
-
- Confirm Contribution
-
-
-
- );
-}
diff --git a/components/campaigns/back-project/index.tsx b/components/campaigns/back-project/index.tsx
deleted file mode 100644
index 77dfe18a..00000000
--- a/components/campaigns/back-project/index.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { BoundlessButton } from '@/components/buttons';
-import { ProjectSubmissionSuccess } from '@/components/project';
-import BoundlessSheet from '@/components/sheet/boundless-sheet';
-import { ProjectSubmissionLoading } from './project-submission-loading';
-import { BackProjectForm } from './back-project-form';
-
-type BackProjectState = 'form' | 'loading' | 'success';
-
-interface BackProjectData {
- amount: string;
- currency: string;
- token: string;
- network: string;
- walletAddress: string;
- keepAnonymous: boolean;
-}
-
-const BackProject = () => {
- const [isSheetOpen, setIsSheetOpen] = useState(false);
- const [backProjectState, setBackProjectState] =
- useState('form');
-
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const handleBackProject = (data: BackProjectData) => {
- setBackProjectState('loading');
- // TODO: Send data to actual API endpoint when backend is ready
-
- // Simulate API call - data will be used when API is implemented
- setTimeout(() => {
- setBackProjectState('success');
- }, 2000);
- };
-
- // const handleContinue = () => {
- // setIsSheetOpen(false)
- // setBackProjectState("form")
- // }
-
- // const handleViewHistory = () => {
- // // Navigate to history page or open history modal
- // setIsSheetOpen(false)
- // // TODO: Implement backing history modal or navigation
- // }
-
- // const handleBack = () => {
- // if (backProjectState === "success") {
- // setBackProjectState("form")
- // }
- // }
-
- const renderSheetContent = () => {
- if (backProjectState === 'success') {
- return (
-
- );
- }
-
- return (
-
-
-
- {backProjectState === 'loading' && (
-
- )}
-
- );
- };
-
- return (
-
-
- {renderSheetContent()}
-
-
- setIsSheetOpen(true)}>
- Back Project
-
-
- );
-};
-
-export default BackProject;
diff --git a/components/campaigns/back-project/project-submission-loading.tsx b/components/campaigns/back-project/project-submission-loading.tsx
deleted file mode 100644
index 88d4bc0b..00000000
--- a/components/campaigns/back-project/project-submission-loading.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-export function ProjectSubmissionLoading() {
- return (
-
-
- {/* Outer spinning ring */}
-
- {/* Inner spinning arc */}
-
-
-
- Processing your contribution...
-
-
- );
-}
diff --git a/components/campaigns/backing-history/backing-history-table.tsx b/components/campaigns/backing-history/backing-history-table.tsx
deleted file mode 100644
index 8a5610a2..00000000
--- a/components/campaigns/backing-history/backing-history-table.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-'use client';
-
-import type React from 'react';
-import { User, Wallet, CheckIcon } from 'lucide-react';
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
-import { format } from 'date-fns';
-
-interface Backer {
- id: string;
- name: string;
- avatar?: string;
- amount: number;
- date: Date;
- walletId: string;
- isAnonymous: boolean;
-}
-
-interface BackingHistoryTableProps {
- backers: Backer[];
-}
-
-const BackingHistoryTable: React.FC = ({
- backers,
-}) => {
- const formatDate = (date: Date) => {
- const now = new Date();
- const diffInDays = Math.floor(
- (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)
- );
-
- if (diffInDays === 0) return 'Today';
- if (diffInDays === 1) return '1d';
- if (diffInDays < 7) return `${diffInDays}d`;
- if (diffInDays < 30) return `${Math.floor(diffInDays / 7)}w`;
- return format(date, 'MMM dd, yyyy');
- };
-
- return (
-
-
- {/* Results Header */}
-
-
Backer
-
Amount
-
Date
-
-
- {/* Backing List */}
-
- {backers.map(backer => (
-
-
-
-
-
-
- {backer.isAnonymous ? (
-
- ) : (
- backer.name.charAt(0)
- )}
-
-
-
-
-
-
-
-
- {backer.name}
-
-
-
- {backer.walletId}
-
-
-
-
- ${backer.amount.toLocaleString()}
-
-
- {formatDate(backer.date)}
-
-
- ))}
-
-
- {backers.length === 0 && (
-
- No backers found matching your criteria
-
- )}
-
-
- );
-};
-
-export default BackingHistoryTable;
diff --git a/components/campaigns/backing-history/filter-popover.tsx b/components/campaigns/backing-history/filter-popover.tsx
deleted file mode 100644
index 9edcfab5..00000000
--- a/components/campaigns/backing-history/filter-popover.tsx
+++ /dev/null
@@ -1,320 +0,0 @@
-'use client';
-
-import type React from 'react';
-import { ArrowUpDown, Calendar, DollarSign, Check } from 'lucide-react';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Slider } from '@/components/ui/slider';
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from '@/components/ui/popover';
-import { Calendar as CalendarComponent } from '@/components/ui/calendar';
-import { format } from 'date-fns';
-
-type IdentityFilter = 'all' | 'identified' | 'anonymous';
-
-interface AdvancedFilterPopoverProps {
- amountRange: number[];
- setAmountRange: (range: number[]) => void;
- dateRange: { from?: Date; to?: Date };
- setDateRange: (range: { from?: Date; to?: Date }) => void;
- identityFilter: IdentityFilter;
- setIdentityFilter: (filter: IdentityFilter) => void;
- showSortPopover: boolean;
- setShowSortPopover: (show: boolean) => void;
- showFromCalendar: boolean;
- setShowFromCalendar: (show: boolean) => void;
- showToCalendar: boolean;
- setShowToCalendar: (show: boolean) => void;
- setQuickDateFilter: (days: number) => void;
- resetFilters: () => void;
- resetDateRange: () => void;
- resetAmountRange: () => void;
- resetIdentityFilter: () => void;
- applyFilters: () => void;
-}
-
-const AdvancedFilterPopover: React.FC = ({
- amountRange,
- setAmountRange,
- dateRange,
- setDateRange,
- identityFilter,
- setIdentityFilter,
- showSortPopover,
- setShowSortPopover,
- showFromCalendar,
- setShowFromCalendar,
- showToCalendar,
- setShowToCalendar,
- setQuickDateFilter,
- resetFilters,
- resetDateRange,
- resetAmountRange,
- resetIdentityFilter,
- applyFilters,
-}) => {
- return (
-
-
-
-
-
-
- {/* Date Range Section */}
-
-
-
Date range
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- setDateRange({ ...dateRange, from: date });
- setShowFromCalendar(false);
- }}
- initialFocus
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- setDateRange({ ...dateRange, to: date });
- setShowToCalendar(false);
- }}
- initialFocus
- />
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Amount Range Section */}
-
-
-
Amount range
-
-
-
-
-
-
-
-
-
- setAmountRange([
- Number.parseInt(e.target.value) || 0,
- amountRange[1],
- ])
- }
- className='border-muted-foreground/20 bg-[#101010] p-5 pl-8 text-white'
- />
-
-
-
-
-
-
-
- setAmountRange([
- amountRange[0],
- Number.parseInt(e.target.value) || 0,
- ])
- }
- className='border-muted-foreground/20 bg-[#101010] p-5 pl-8 text-white'
- />
-
-
-
-
-
-
-
- {/* Identity Type Section */}
-
-
-
Identity Type
-
-
-
-
-
-
-
-
-
- {/* Action Buttons */}
-
-
-
-
-
-
-
- );
-};
-
-export default AdvancedFilterPopover;
diff --git a/components/campaigns/backing-history/index.tsx b/components/campaigns/backing-history/index.tsx
deleted file mode 100644
index dc85c3e2..00000000
--- a/components/campaigns/backing-history/index.tsx
+++ /dev/null
@@ -1,171 +0,0 @@
-'use client';
-
-import type React from 'react';
-import { useState, useMemo } from 'react';
-import { Search } from 'lucide-react';
-import { Input } from '@/components/ui/input';
-import BoundlessSheet from '@/components/sheet/boundless-sheet';
-import SortFilterPopover from './sort-filter-popover';
-import AdvancedFilterPopover from './filter-popover';
-import BackingHistoryTable from './backing-history-table';
-
-interface Backer {
- id: string;
- name: string;
- avatar?: string;
- amount: number;
- date: Date;
- walletId: string;
- isAnonymous: boolean;
-}
-
-interface BackingHistoryProps {
- open: boolean;
- setOpen: (open: boolean) => void;
- backers: Backer[];
-}
-
-type SortOption = 'newest' | 'oldest' | 'alphabetical' | 'highest' | 'lowest';
-type IdentityFilter = 'all' | 'identified' | 'anonymous';
-
-const BackingHistory: React.FC = ({
- open,
- setOpen,
- backers,
-}) => {
- const [searchQuery, setSearchQuery] = useState('');
- const [sortBy, setSortBy] = useState('newest');
- const [amountRange, setAmountRange] = useState([0, 10000]);
- const [dateRange, setDateRange] = useState<{ from?: Date; to?: Date }>({});
- const [identityFilter, setIdentityFilter] = useState('all');
- const [showFilterPopover, setShowFilterPopover] = useState(false);
- const [showSortPopover, setShowSortPopover] = useState(false);
- const [showFromCalendar, setShowFromCalendar] = useState(false);
- const [showToCalendar, setShowToCalendar] = useState(false);
-
- const setQuickDateFilter = (days: number) => {
- const today = new Date();
- const pastDate = new Date(today.getTime() - days * 24 * 60 * 60 * 1000);
- setDateRange({ from: pastDate, to: today });
- };
-
- const resetFilters = () => {
- setSearchQuery('');
- setSortBy('newest');
- setAmountRange([0, 10000]);
- setDateRange({});
- setIdentityFilter('all');
- };
-
- const resetDateRange = () => {
- setDateRange({});
- };
-
- const resetAmountRange = () => {
- setAmountRange([10, 1000]);
- };
-
- const resetIdentityFilter = () => {
- setIdentityFilter('all');
- };
-
- const applyFilters = () => {
- setShowSortPopover(false);
- };
-
- const filteredAndSortedBackers = useMemo(() => {
- const filtered = backers.filter(backer => {
- const matchesSearch =
- backer.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
- backer.walletId.toLowerCase().includes(searchQuery.toLowerCase());
-
- const matchesAmount =
- backer.amount >= amountRange[0] && backer.amount <= amountRange[1];
-
- const matchesDate =
- !dateRange.from ||
- !dateRange.to ||
- (backer.date >= dateRange.from && backer.date <= dateRange.to);
-
- const matchesIdentity =
- identityFilter === 'all' ||
- (identityFilter === 'anonymous' && backer.isAnonymous) ||
- (identityFilter === 'identified' && !backer.isAnonymous);
-
- return matchesSearch && matchesAmount && matchesDate && matchesIdentity;
- });
-
- filtered.sort((a, b) => {
- switch (sortBy) {
- case 'newest':
- return b.date.getTime() - a.date.getTime();
- case 'oldest':
- return a.date.getTime() - b.date.getTime();
- case 'alphabetical':
- return a.name.localeCompare(b.name);
- case 'highest':
- return b.amount - a.amount;
- case 'lowest':
- return a.amount - b.amount;
- default:
- return 0;
- }
- });
-
- return filtered;
- }, [backers, searchQuery, sortBy, amountRange, dateRange, identityFilter]);
-
- return (
-
-
-
-
Backing History
- {/* Search and Controls */}
-
-
-
- setSearchQuery(e.target.value)}
- className='border-muted-foreground/20 text-placeholder placeholder:text-muted-foreground w-full bg-[#1c1c1c] py-5 pl-10 placeholder:font-medium focus:outline-none'
- />
-
-
-
-
-
-
-
-
- );
-};
-
-export default BackingHistory;
diff --git a/components/campaigns/backing-history/sort-filter-popover.tsx b/components/campaigns/backing-history/sort-filter-popover.tsx
deleted file mode 100644
index 3bd2a0c3..00000000
--- a/components/campaigns/backing-history/sort-filter-popover.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-'use client';
-
-import type React from 'react';
-import { Check, Filter } from 'lucide-react';
-import { Button } from '@/components/ui/button';
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from '@/components/ui/popover';
-
-type SortOption = 'newest' | 'oldest' | 'alphabetical' | 'highest' | 'lowest';
-
-interface SortFilterPopoverProps {
- sortBy: SortOption;
- setSortBy: (sort: SortOption) => void;
- showFilterPopover: boolean;
- setShowFilterPopover: (show: boolean) => void;
-}
-
-const SortFilterPopover: React.FC = ({
- sortBy,
- setSortBy,
- showFilterPopover,
- setShowFilterPopover,
-}) => {
- return (
-
-
-
-
-
-
- {/* Time based Section */}
-
-
- Time based
-
-
-
-
-
-
-
- {/* Backer name Section */}
-
-
- Backer name
-
-
-
-
- {/* Funding amount Section */}
-
-
- Funding amount
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default SortFilterPopover;
diff --git a/components/campaigns/launch/LaunchCampaignflow.tsx b/components/campaigns/launch/LaunchCampaignflow.tsx
deleted file mode 100644
index e69de29b..00000000
diff --git a/components/comments/CommentModerationDashboard.tsx b/components/comments/CommentModerationDashboard.tsx
new file mode 100644
index 00000000..745b8823
--- /dev/null
+++ b/components/comments/CommentModerationDashboard.tsx
@@ -0,0 +1,507 @@
+'use client';
+
+import React, { useState } from 'react';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import {
+ AlertTriangle,
+ EyeOff,
+ Trash2,
+ CheckCircle,
+ XCircle,
+} from 'lucide-react';
+import {
+ Comment,
+ CommentReport,
+ ReportStatus,
+ CommentStatus,
+ ReportReason,
+} from '@/types/comment';
+import { cn } from '@/lib/utils';
+import { toast } from 'sonner';
+
+// Mock data for demonstration - in real app this would come from API
+const mockReports: CommentReport[] = [
+ {
+ id: '1',
+ commentId: 'comment-1',
+ reportedBy: 'user-1',
+ reporter: {
+ id: 'user-1',
+ name: 'Alice Johnson',
+ username: 'alice_j',
+ image: '/user-avatar.png',
+ },
+ reason: ReportReason.SPAM,
+ description: 'This comment appears to be promotional spam',
+ status: ReportStatus.PENDING,
+ createdAt: '2024-01-15T10:30:00Z',
+ },
+ {
+ id: '2',
+ commentId: 'comment-2',
+ reportedBy: 'user-2',
+ reporter: {
+ id: 'user-2',
+ name: 'Bob Smith',
+ username: 'bob_smith',
+ image: '/user-avatar.png',
+ },
+ reason: ReportReason.HARASSMENT,
+ description: 'Harassing language towards other users',
+ status: ReportStatus.PENDING,
+ createdAt: '2024-01-15T11:15:00Z',
+ },
+];
+
+const mockComments: Comment[] = [
+ {
+ id: 'comment-1',
+ content:
+ 'This is an amazing project! Check out my website for more details about similar work.',
+ entityType: 'PROJECT' as any,
+ entityId: 'project-1',
+ authorId: 'user-3',
+ author: {
+ id: 'user-3',
+ name: 'Charlie Wilson',
+ username: 'charlie_w',
+ image: '/user-avatar.png',
+ },
+ status: CommentStatus.ACTIVE,
+ isEdited: false,
+ reactionCount: 5,
+ createdAt: '2024-01-15T09:00:00Z',
+ updatedAt: '2024-01-15T09:00:00Z',
+ reactions: [],
+ reports: [],
+ _count: {
+ replies: 0,
+ reactions: 5,
+ reports: 0,
+ },
+ },
+ {
+ id: 'comment-2',
+ content:
+ 'I disagree with this approach completely. Your implementation is flawed.',
+ entityType: 'PROJECT' as any,
+ entityId: 'project-1',
+ authorId: 'user-4',
+ author: {
+ id: 'user-4',
+ name: 'Diana Prince',
+ username: 'diana_p',
+ image: '/user-avatar.png',
+ },
+ status: CommentStatus.ACTIVE,
+ isEdited: false,
+ reactionCount: 2,
+ createdAt: '2024-01-15T10:00:00Z',
+ updatedAt: '2024-01-15T10:00:00Z',
+ reactions: [],
+ reports: [],
+ _count: {
+ replies: 0,
+ reactions: 2,
+ reports: 0,
+ },
+ },
+];
+
+interface CommentModerationDashboardProps {
+ className?: string;
+}
+
+export function CommentModerationDashboard({
+ className,
+}: CommentModerationDashboardProps) {
+ const [reports, setReports] = useState(mockReports);
+ const [comments, setComments] = useState(mockComments);
+ const [selectedReport, setSelectedReport] = useState(
+ null
+ );
+ const [resolution, setResolution] = useState('');
+ const [action, setAction] = useState<'approve' | 'hide' | 'delete' | ''>('');
+
+ // Get comment by ID
+ const getCommentById = (commentId: string) => {
+ return comments.find(c => c.id === commentId);
+ };
+
+ // Handle report resolution
+ const handleResolveReport = async (reportId: string) => {
+ if (!action || !resolution.trim()) {
+ toast.error('Please select an action and provide a resolution');
+ return;
+ }
+
+ try {
+ // In real app, call API to resolve report
+ setReports(prev =>
+ prev.map(report =>
+ report.id === reportId
+ ? { ...report, status: ReportStatus.RESOLVED }
+ : report
+ )
+ );
+
+ // Update comment status if needed
+ if (selectedReport) {
+ const newStatus =
+ action === 'hide'
+ ? CommentStatus.HIDDEN
+ : action === 'delete'
+ ? CommentStatus.DELETED
+ : CommentStatus.ACTIVE;
+
+ setComments(prev =>
+ prev.map(comment =>
+ comment.id === selectedReport.commentId
+ ? { ...comment, status: newStatus }
+ : comment
+ )
+ );
+ }
+
+ toast.success('Report resolved successfully');
+ setSelectedReport(null);
+ setResolution('');
+ setAction('');
+ } catch {
+ toast.error('Failed to resolve report');
+ }
+ };
+
+ // Handle dismiss report
+ const handleDismissReport = async (reportId: string) => {
+ try {
+ setReports(prev =>
+ prev.map(report =>
+ report.id === reportId
+ ? { ...report, status: ReportStatus.DISMISSED }
+ : report
+ )
+ );
+ toast.success('Report dismissed');
+ } catch {
+ toast.error('Failed to dismiss report');
+ }
+ };
+
+ // Filter reports by status
+ const pendingReports = reports.filter(r => r.status === ReportStatus.PENDING);
+ const resolvedReports = reports.filter(
+ r => r.status === ReportStatus.RESOLVED
+ );
+ const dismissedReports = reports.filter(
+ r => r.status === ReportStatus.DISMISSED
+ );
+
+ // Get reason badge color
+ const getReasonBadgeColor = (reason: ReportReason) => {
+ switch (reason) {
+ case ReportReason.SPAM:
+ return 'bg-yellow-100 text-yellow-800';
+ case ReportReason.HARASSMENT:
+ return 'bg-red-100 text-red-800';
+ case ReportReason.HATE_SPEECH:
+ return 'bg-red-100 text-red-800';
+ case ReportReason.INAPPROPRIATE_CONTENT:
+ return 'bg-orange-100 text-orange-800';
+ case ReportReason.COPYRIGHT_VIOLATION:
+ return 'bg-purple-100 text-purple-800';
+ default:
+ return 'bg-gray-100 text-gray-800';
+ }
+ };
+
+ return (
+
+
+
+
Comment Moderation
+
+ Manage reported comments and moderate content
+
+
+
+
+ {pendingReports.length} Pending
+
+
+ {resolvedReports.length} Resolved
+
+
+
+
+
+
+
+ Pending Reports ({pendingReports.length})
+
+
+ Resolved ({resolvedReports.length})
+
+
+ Dismissed ({dismissedReports.length})
+
+
+
+
+ {pendingReports.length === 0 ? (
+
+
+
+
+
+ All caught up!
+
+
No pending reports to review
+
+
+
+ ) : (
+
+ {pendingReports.map(report => {
+ const comment = getCommentById(report.commentId);
+ return (
+
+
+
+
+
+
+
+ {report.reporter.name.charAt(0)}
+
+
+
+
+
+ {report.reporter.name}
+
+
+ {report.reason.replace('_', ' ')}
+
+
+
+ Reported{' '}
+ {new Date(report.createdAt).toLocaleDateString()}
+
+ {report.description && (
+
+ "{report.description}"
+
+ )}
+
+
+
+
+
+
+
+
+
+ {comment && (
+
+
+
+
+
+
+ {comment.author.name.charAt(0)}
+
+
+
+
+
+ {comment.author.name}
+
+
+ {new Date(
+ comment.createdAt
+ ).toLocaleDateString()}
+
+
+
+ {comment.content}
+
+
+
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
+ {resolvedReports.map(report => (
+
+
+
+
+
+
+ Report #{report.id} - Resolved
+
+
+
+ Resolved
+
+
+
+
+ ))}
+
+
+
+ {dismissedReports.map(report => (
+
+
+
+
+
+
+ Report #{report.id} - Dismissed
+
+
+
Dismissed
+
+
+
+ ))}
+
+
+
+ {/* Resolution Modal/Dialog */}
+ {selectedReport && (
+
+
+ Resolve Report
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/components/comments/GenericCommentThread.tsx b/components/comments/GenericCommentThread.tsx
new file mode 100644
index 00000000..a3efafe8
--- /dev/null
+++ b/components/comments/GenericCommentThread.tsx
@@ -0,0 +1,762 @@
+'use client';
+
+import React, { useState, useMemo } from 'react';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { Button } from '@/components/ui/button';
+import { Textarea } from '@/components/ui/textarea';
+import { Badge } from '@/components/ui/badge';
+import {
+ AlertTriangle,
+ Trash2,
+ Edit,
+ MoreHorizontal,
+ Send,
+ ChevronDown,
+ ChevronUp,
+ Loader2,
+ Flag,
+ MessageCircle,
+ EyeOff,
+} from 'lucide-react';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { cn } from '@/lib/utils';
+import {
+ Comment,
+ CommentEntityType,
+ ReactionType,
+ ReportReason,
+ CommentStatus,
+ UseCommentsReturn,
+ UseCreateCommentReturn,
+ UseUpdateCommentReturn,
+ UseDeleteCommentReturn,
+ UseReportCommentReturn,
+ UseReactionsReturn,
+} from '@/types/comment';
+import { getReactionSummary } from '@/lib/utils/reactions';
+import {
+ validateCommentContent,
+ sanitizeCommentContent,
+} from '@/lib/utils/comment-validation';
+import { useCommentRealtime } from '@/hooks/use-comment-realtime';
+import { toast } from 'sonner';
+
+interface GenericCommentThreadProps {
+ entityType: CommentEntityType;
+ entityId: string;
+ currentUser?: {
+ id: string;
+ name: string;
+ username?: string;
+ image?: string;
+ isModerator?: boolean;
+ };
+ // Hook instances passed from parent
+ commentsHook: UseCommentsReturn;
+ createCommentHook: UseCreateCommentReturn;
+ updateCommentHook: UseUpdateCommentReturn;
+ deleteCommentHook: UseDeleteCommentReturn;
+ reportCommentHook: UseReportCommentReturn;
+ reactionsHook: UseReactionsReturn;
+ // Optional customization
+ maxDepth?: number;
+ showReactions?: boolean;
+ showReporting?: boolean;
+ showModeration?: boolean;
+ className?: string;
+}
+
+export function GenericCommentThread({
+ entityType,
+ entityId,
+ currentUser,
+ commentsHook,
+ createCommentHook,
+ updateCommentHook,
+ deleteCommentHook,
+ reportCommentHook,
+ reactionsHook,
+ // maxDepth = 3,
+ showReactions = true,
+ showReporting = true,
+ showModeration = currentUser?.isModerator,
+ className,
+}: GenericCommentThreadProps) {
+ const [newComment, setNewComment] = useState('');
+ const [commentValidation, setCommentValidation] = useState({
+ isValid: true,
+ errors: [] as string[],
+ });
+
+ // Real-time updates
+ const { isConnected } = useCommentRealtime(
+ { entityType, entityId, userId: currentUser?.id, enabled: true },
+ {
+ onCommentCreated: () => {
+ commentsHook.refetch();
+ toast.success('New comment added');
+ },
+ onCommentUpdated: () => {
+ commentsHook.refetch();
+ },
+ onCommentDeleted: () => {
+ commentsHook.refetch();
+ },
+ onReactionAdded: () => {
+ commentsHook.refetch();
+ },
+ onReactionRemoved: () => {
+ commentsHook.refetch();
+ },
+ onCommentStatusChanged: () => {
+ commentsHook.refetch();
+ },
+ }
+ );
+
+ const handleSubmitComment = async () => {
+ const sanitizedContent = sanitizeCommentContent(newComment);
+ const validation = validateCommentContent(sanitizedContent);
+
+ setCommentValidation(validation);
+
+ if (!validation.isValid) {
+ toast.error('Please fix the validation errors');
+ return;
+ }
+
+ try {
+ await createCommentHook.createComment({
+ content: sanitizedContent,
+ entityType,
+ entityId,
+ });
+ setNewComment('');
+ setCommentValidation({ isValid: true, errors: [] });
+ toast.success('Comment posted successfully');
+ } catch {
+ toast.error('Failed to post comment');
+ }
+ };
+
+ const handleCommentValidation = (content: string) => {
+ const sanitized = sanitizeCommentContent(content);
+ const validation = validateCommentContent(sanitized);
+ setCommentValidation(validation);
+ return validation;
+ };
+
+ if (commentsHook.loading && commentsHook.comments.length === 0) {
+ return (
+
+
+ Loading comments...
+
+ );
+ }
+
+ if (commentsHook.error) {
+ return (
+
+
+ Error loading comments: {commentsHook.error}
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Connection Status */}
+ {!isConnected && (
+
+
+
+
+ Real-time updates unavailable. Changes may not appear immediately.
+
+
+
+ )}
+
+ {/* Comment Input */}
+
+
Comments
+
+
+
+
+ {currentUser?.name.charAt(0) || 'U'}
+
+
+
+
+
+
+ {/* Comments List */}
+
+ {commentsHook.comments.length === 0 ? (
+
+
+
+ No comments yet
+
+
+ Be the first to share your thoughts!
+
+
+ ) : (
+ commentsHook.comments.map(comment => (
+
{
+ try {
+ await createCommentHook.createComment({
+ content: sanitizeCommentContent(content),
+ parentId,
+ entityType,
+ entityId,
+ });
+ toast.success('Reply posted successfully');
+ } catch {
+ toast.error('Failed to post reply');
+ }
+ }}
+ onUpdate={async (commentId, content) => {
+ try {
+ await updateCommentHook.updateComment(commentId, {
+ content: sanitizeCommentContent(content),
+ });
+ toast.success('Comment updated');
+ } catch {
+ toast.error('Failed to update comment');
+ }
+ }}
+ onDelete={async commentId => {
+ try {
+ await deleteCommentHook.deleteComment(commentId);
+ toast.success('Comment deleted');
+ } catch {
+ toast.error('Failed to delete comment');
+ }
+ }}
+ onReport={async (commentId, reason, description) => {
+ try {
+ await reportCommentHook.reportComment(commentId, {
+ reason,
+ description,
+ });
+ toast.success('Comment reported');
+ } catch {
+ toast.error('Failed to report comment');
+ }
+ }}
+ onReaction={async (commentId, reactionType) => {
+ try {
+ await reactionsHook.addReaction(commentId, reactionType);
+ } catch {
+ toast.error('Failed to add reaction');
+ }
+ }}
+ onRemoveReaction={async (commentId, reactionType) => {
+ try {
+ await reactionsHook.removeReaction(commentId, reactionType);
+ } catch {
+ toast.error('Failed to remove reaction');
+ }
+ }}
+ showReactions={showReactions}
+ showReporting={showReporting}
+ showModeration={showModeration}
+ // reactionsHook={reactionsHook}
+ />
+ ))
+ )}
+
+
+ {/* Load More */}
+ {commentsHook.pagination.hasNext && (
+
+
+
+ )}
+
+ );
+}
+
+// Individual Comment Item Component
+interface CommentItemProps {
+ comment: Comment;
+ currentUser?: {
+ id: string;
+ name: string;
+ username?: string;
+ image?: string;
+ isModerator?: boolean;
+ };
+ onAddReply: (parentId: string, content: string) => Promise;
+ onUpdate: (commentId: string, content: string) => Promise;
+ onDelete: (commentId: string) => Promise;
+ onReport: (
+ commentId: string,
+ reason: ReportReason,
+ description?: string
+ ) => Promise;
+ onReaction: (commentId: string, reactionType: ReactionType) => Promise;
+ onRemoveReaction: (
+ commentId: string,
+ reactionType: ReactionType
+ ) => Promise;
+ depth?: number;
+ showReactions?: boolean;
+ showReporting?: boolean;
+ showModeration?: boolean;
+}
+
+function CommentItem({
+ comment,
+ currentUser,
+ onAddReply,
+ onUpdate,
+ onDelete,
+ onReport,
+ onReaction,
+ onRemoveReaction,
+ depth = 0,
+ showReactions = true,
+ showReporting = true,
+ showModeration = false,
+}: CommentItemProps) {
+ const [showReplies, setShowReplies] = useState(false);
+ const [showReplyInput, setShowReplyInput] = useState(false);
+ const [isEditing, setIsEditing] = useState(false);
+ const [editContent, setEditContent] = useState(comment.content);
+ const [replyContent, setReplyContent] = useState('');
+ const [showReportForm, setShowReportForm] = useState(false);
+ const [reportReason, setReportReason] = useState('');
+ const [reportDescription, setReportDescription] = useState('');
+
+ const isOwner = currentUser?.id === comment.authorId;
+ const canEdit = isOwner && comment.status === CommentStatus.ACTIVE;
+ const canDelete = isOwner || showModeration;
+ const canReply = comment.status === CommentStatus.ACTIVE;
+
+ const reactionSummary = useMemo(() => {
+ // For now, use a simple reaction summary based on reactionCount
+ // In a full implementation, this would transform CommentReaction[] to the expected format
+ const mockReactions =
+ comment.reactionCount > 0
+ ? [
+ {
+ type: ReactionType.LIKE as ReactionType,
+ count: comment.reactionCount,
+ },
+ ]
+ : [];
+ return getReactionSummary(mockReactions, comment.userReaction);
+ }, [comment.reactionCount, comment.userReaction]);
+
+ const handleEdit = async () => {
+ if (editContent.trim() && editContent !== comment.content) {
+ await onUpdate(comment.id, editContent);
+ setIsEditing(false);
+ }
+ };
+
+ const handleReply = async () => {
+ if (replyContent.trim()) {
+ await onAddReply(comment.id, replyContent);
+ setReplyContent('');
+ setShowReplyInput(false);
+ setShowReplies(true);
+ }
+ };
+
+ const handleReport = async () => {
+ if (reportReason) {
+ await onReport(comment.id, reportReason, reportDescription);
+ setShowReportForm(false);
+ setReportReason('');
+ setReportDescription('');
+ }
+ };
+
+ const handleReaction = async (reactionType: ReactionType) => {
+ if (comment.userReaction === reactionType) {
+ await onRemoveReaction(comment.id, reactionType);
+ } else {
+ if (comment.userReaction) {
+ await onRemoveReaction(comment.id, comment.userReaction);
+ }
+ await onReaction(comment.id, reactionType);
+ }
+ };
+
+ // Don't render hidden/deleted comments unless user is moderator
+ if (comment.status !== CommentStatus.ACTIVE && !showModeration) {
+ return null;
+ }
+
+ return (
+ 0 && 'ml-8 md:ml-12')}>
+
+
+
+ {comment.author.name.charAt(0)}
+
+
+
+
+
+
+
+
+ {comment.author.username && (
+
+ @{comment.author.username}
+
+ )}
+
+ {new Date(comment.createdAt).toLocaleDateString()}
+
+ {comment.isEdited && (
+
(edited)
+ )}
+ {comment.status !== CommentStatus.ACTIVE && (
+
+ {comment.status === CommentStatus.HIDDEN && (
+
+ )}
+ {comment.status === CommentStatus.DELETED && (
+
+ )}
+ {comment.status === CommentStatus.PENDING_MODERATION && (
+
+ )}
+ {comment.status.replace('_', ' ')}
+
+ )}
+
+
+ {isEditing ? (
+
+ ) : (
+
+ {comment.content}
+
+ )}
+
+
+ {/* Actions Menu */}
+
+
+
+
+
+ {canEdit && (
+ setIsEditing(true)}
+ className='text-white hover:bg-[#2B2B2B]'
+ >
+
+ Edit
+
+ )}
+ {canDelete && (
+ <>
+ onDelete(comment.id)}
+ className='text-red-400 hover:bg-red-900/20'
+ >
+
+ Delete
+
+ >
+ )}
+ {showReporting && !isOwner && (
+ <>
+
+ setShowReportForm(true)}
+ className='text-orange-400 hover:bg-orange-900/20'
+ >
+
+ Report
+
+ >
+ )}
+
+
+
+
+ {/* Reactions */}
+ {showReactions && (
+
+ {reactionSummary.topReactions.map(reaction => {
+ const config = reaction.config;
+ return (
+
+ );
+ })}
+
+
+ )}
+
+ {/* Reply Input */}
+ {showReplyInput && (
+
+
+
+
+
+ {currentUser?.name.charAt(0) || 'U'}
+
+
+
+
+
+ )}
+
+ {/* Action Buttons */}
+
+ {canReply && (
+
+ )}
+
+ {comment._count.replies > 0 && (
+
+ )}
+
+
+ {/* Report Form */}
+ {showReportForm && (
+
+
+ Report Comment
+
+
+
+ )}
+
+ {/* Nested Replies */}
+ {/* Note: Backend returns _count.replies instead of actual reply objects */}
+ {/* To implement nested replies, you'd need to fetch replies separately */}
+ {/* For now, we show the count but don't render nested replies */}
+ {comment._count.replies > 0 && (
+
+ {comment._count.replies}{' '}
+ {comment._count.replies === 1 ? 'reply' : 'replies'}
+
+ )}
+
+
+ );
+}
diff --git a/components/escrow/InitializeEscrowButton.tsx b/components/escrow/InitializeEscrowButton.tsx
index 31d34bf6..4036a4db 100644
--- a/components/escrow/InitializeEscrowButton.tsx
+++ b/components/escrow/InitializeEscrowButton.tsx
@@ -51,7 +51,8 @@ export const InitializeEscrowButton = () => {
platformFee: 4, // Commission that the platform will receive (4%)
trustline: {
// USDC trustline address
- address: 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA',
+ address: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5',
+ symbol: 'USDC',
},
// Roles for multi-release (Omit)
roles: {
diff --git a/components/follow/EntityCard.tsx b/components/follow/EntityCard.tsx
new file mode 100644
index 00000000..c7380453
--- /dev/null
+++ b/components/follow/EntityCard.tsx
@@ -0,0 +1,155 @@
+'use client';
+
+import Image from 'next/image';
+import Link from 'next/link';
+import { Calendar, ExternalLink } from 'lucide-react';
+import { Card, CardContent } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { cn } from '@/lib/utils';
+import { EntityCardProps } from '@/types/follow';
+
+const entityTypeLabels = {
+ USER: 'User',
+ PROJECT: 'Project',
+ ORGANIZATION: 'Organization',
+ CROWDFUNDING_CAMPAIGN: 'Campaign',
+ BOUNTY: 'Bounty',
+ GRANT: 'Grant',
+ HACKATHON: 'Hackathon',
+};
+
+const entityTypeColors = {
+ USER: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
+ PROJECT:
+ 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
+ ORGANIZATION:
+ 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
+ CROWDFUNDING_CAMPAIGN:
+ 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
+ BOUNTY: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300',
+ GRANT: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300',
+ HACKATHON:
+ 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300',
+};
+
+export const EntityCard = ({
+ entity,
+ entityType,
+ followedAt,
+ className,
+}: EntityCardProps) => {
+ const getEntityUrl = () => {
+ switch (entityType) {
+ case 'USER':
+ return `/user/${entity.username}`;
+ case 'PROJECT':
+ return `/projects/${entity.id}`;
+ case 'ORGANIZATION':
+ return `/organizations/${entity.id}`;
+ case 'CROWDFUNDING_CAMPAIGN':
+ return `/campaigns/${entity.id}`;
+ case 'BOUNTY':
+ return `/bounties/${entity.id}`;
+ case 'GRANT':
+ return `/grants/${entity.id}`;
+ case 'HACKATHON':
+ return `/hackathons/${entity.id}`;
+ default:
+ return '#';
+ }
+ };
+
+ const formatFollowedAt = (dateString: string) => {
+ const date = new Date(dateString);
+ return date.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ };
+
+ return (
+
+
+
+
+ {entity.image || entity.logo || entity.banner ? (
+
+ ) : (
+
+
+ {(entity.title || entity.name || 'E').charAt(0).toUpperCase()}
+
+
+ )}
+
+
+ {/* Entity Info */}
+
+
+
+
+
+ {entity.title || entity.name}
+
+
+
+ {entity.tagline && (
+
+ {entity.tagline}
+
+ )}
+
+
+
+ {entityTypeLabels[entityType]}
+
+
+ {entity.category && (
+
+ {entity.category}
+
+ )}
+
+ {entity.status && (
+
+ {entity.status}
+
+ )}
+
+
+
+
+
+
+
+
+ {/* Followed date */}
+
+
+ Followed {formatFollowedAt(followedAt)}
+
+
+
+
+
+ );
+};
diff --git a/components/follow/FollowButton.tsx b/components/follow/FollowButton.tsx
new file mode 100644
index 00000000..5aa33e8d
--- /dev/null
+++ b/components/follow/FollowButton.tsx
@@ -0,0 +1,85 @@
+'use client';
+
+import { useState } from 'react';
+import { UserPlus, UserMinus } from 'lucide-react';
+import LoadingSpinner from '@/components/LoadingSpinner';
+import { cn } from '@/lib/utils';
+import { toast } from 'sonner';
+import { FollowButtonProps } from '@/types/follow';
+import { useFollow } from '@/hooks/use-follow';
+import { BoundlessButton } from '../buttons';
+
+export const FollowButton = ({
+ entityType,
+ entityId,
+ initialIsFollowing = false,
+ onFollowChange,
+ className,
+}: FollowButtonProps) => {
+ const [optimisticIsFollowing, setOptimisticIsFollowing] =
+ useState(initialIsFollowing);
+
+ const { isFollowing, isLoading, toggleFollow } = useFollow(
+ entityType,
+ entityId,
+ initialIsFollowing
+ );
+
+ const displayIsFollowing = isLoading ? optimisticIsFollowing : isFollowing;
+
+ const handleToggleFollow = async () => {
+ // Optimistic update
+ const newFollowState = !displayIsFollowing;
+ setOptimisticIsFollowing(newFollowState);
+
+ try {
+ await toggleFollow();
+
+ // Notify parent component of the change
+ onFollowChange?.(newFollowState, newFollowState ? 1 : -1);
+
+ // Show success toast
+ toast.success(
+ newFollowState
+ ? `You are now following this ${entityType.toLowerCase()}`
+ : `You unfollowed this ${entityType.toLowerCase()}`
+ );
+ } catch {
+ setOptimisticIsFollowing(displayIsFollowing);
+ const errorMessage = 'Failed to update follow status';
+ toast.error(errorMessage);
+ }
+ };
+
+ return (
+
+ {isLoading ? (
+
+ ) : displayIsFollowing ? (
+
+ ) : (
+
+ )}
+
+ {isLoading
+ ? 'Updating...'
+ : displayIsFollowing
+ ? 'Following'
+ : 'Follow'}
+
+
+ );
+};
diff --git a/components/follow/FollowStats.tsx b/components/follow/FollowStats.tsx
new file mode 100644
index 00000000..b5c4473f
--- /dev/null
+++ b/components/follow/FollowStats.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import { Users, UserCheck } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { FollowStatsProps } from '@/types/follow';
+import { useFollowStats } from '@/hooks/use-follow-stats';
+
+export const FollowStats = ({ userId, className }: FollowStatsProps) => {
+ const { stats, isLoading, error } = useFollowStats(userId);
+
+ if (error) {
+ return (
+
+ Error loading stats
+
+ );
+ }
+
+ if (isLoading || !stats) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {stats.following}
+ Following
+
+
+
+
+ {stats.followers}
+ Followers
+
+
+ );
+};
diff --git a/components/follow/FollowersList.tsx b/components/follow/FollowersList.tsx
new file mode 100644
index 00000000..20a4a0dd
--- /dev/null
+++ b/components/follow/FollowersList.tsx
@@ -0,0 +1,102 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import LoadingSpinner from '@/components/LoadingSpinner';
+import { cn } from '@/lib/utils';
+import { FollowersListProps } from '@/types/follow';
+import { useFollowersList } from '@/hooks/use-followers-list';
+import { UserCard } from './UserCard';
+import EmptyState from '../EmptyState';
+
+export const FollowersList = ({
+ entityType,
+ entityId,
+ className,
+}: FollowersListProps) => {
+ const { followers, isLoading, error, hasMore, loadMore } = useFollowersList(
+ entityType,
+ entityId
+ );
+
+ if (error) {
+ return (
+
+
+
Failed to load followers list
+
{error}
+
+
+
+ );
+ }
+
+ if (isLoading && followers.length === 0) {
+ return (
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (followers.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {followers.map(follow => (
+
+ ))}
+
+
+ {hasMore && (
+
+
+
+ )}
+
+ );
+};
diff --git a/components/follow/FollowingList.tsx b/components/follow/FollowingList.tsx
new file mode 100644
index 00000000..1a272700
--- /dev/null
+++ b/components/follow/FollowingList.tsx
@@ -0,0 +1,106 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import LoadingSpinner from '@/components/LoadingSpinner';
+import { cn } from '@/lib/utils';
+import { FollowingListProps } from '@/types/follow';
+import { useFollowingList } from '@/hooks/use-following-list';
+import { EntityCard } from './EntityCard';
+import EmptyState from '../EmptyState';
+
+export const FollowingList = ({
+ userId,
+ entityType,
+ className,
+}: FollowingListProps) => {
+ const { following, isLoading, error, hasMore, loadMore } = useFollowingList(
+ userId,
+ entityType
+ );
+
+ if (error) {
+ return (
+
+
+
Failed to load following list
+
{error}
+
+
+
+ );
+ }
+
+ if (isLoading && following.length === 0) {
+ return (
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (following.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {following.map(follow => (
+
+ ))}
+
+
+ {hasMore && (
+
+
+
+ )}
+
+ );
+};
diff --git a/components/follow/UserCard.tsx b/components/follow/UserCard.tsx
new file mode 100644
index 00000000..5244a3dc
--- /dev/null
+++ b/components/follow/UserCard.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import Image from 'next/image';
+import Link from 'next/link';
+import { Calendar, ExternalLink } from 'lucide-react';
+import { Card, CardContent } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
+import { UserCardProps } from '@/types/follow';
+
+export const UserCard = ({ user, followedAt, className }: UserCardProps) => {
+ const formatFollowedAt = (dateString: string) => {
+ const date = new Date(dateString);
+ return date.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ };
+
+ return (
+
+
+
+ {/* User Avatar */}
+
+ {user.image ? (
+
+ ) : (
+
+
+ {(user.name || user.username || 'U').charAt(0).toUpperCase()}
+
+
+ )}
+
+
+ {/* User Info */}
+
+
+
+
+
+ {user.name || 'Anonymous User'}
+
+
+
+ {user.username && (
+
+ @{user.username}
+
+ )}
+
+
+
+
+
+
+
+ {/* Followed date */}
+
+
+ Followed {formatFollowedAt(followedAt)}
+
+
+
+
+
+ );
+};
diff --git a/components/follow/index.ts b/components/follow/index.ts
new file mode 100644
index 00000000..a9ae8416
--- /dev/null
+++ b/components/follow/index.ts
@@ -0,0 +1,6 @@
+export { FollowButton } from './FollowButton';
+export { FollowStats } from './FollowStats';
+export { FollowingList } from './FollowingList';
+export { FollowersList } from './FollowersList';
+export { EntityCard } from './EntityCard';
+export { UserCard } from './UserCard';
diff --git a/components/hackathons/FeaturedHackathonsCarousel.tsx b/components/hackathons/FeaturedHackathonsCarousel.tsx
index 81e8b244..5d1f324d 100644
--- a/components/hackathons/FeaturedHackathonsCarousel.tsx
+++ b/components/hackathons/FeaturedHackathonsCarousel.tsx
@@ -84,7 +84,7 @@ const FeaturedHackathonsCarousel = ({
return (
diff --git a/components/hackathons/HackathonComments.tsx b/components/hackathons/HackathonComments.tsx
new file mode 100644
index 00000000..62ea783e
--- /dev/null
+++ b/components/hackathons/HackathonComments.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import { useAuth } from '@/hooks/use-auth';
+import { GenericCommentThread } from '@/components/comments/GenericCommentThread';
+import { useCommentSystem } from '@/hooks/use-comment-system';
+import { CommentEntityType } from '@/types/comment';
+
+interface HackathonCommentsProps {
+ hackathonId: string;
+}
+
+export function HackathonComments({ hackathonId }: HackathonCommentsProps) {
+ const { user } = useAuth();
+
+ // Initialize the comment system for this hackathon
+ const commentSystem = useCommentSystem({
+ entityType: CommentEntityType.HACKATHON,
+ entityId: hackathonId,
+ page: 1,
+ limit: 20,
+ enabled: true,
+ });
+
+ // Current user info for the comment system
+ const currentUser = user
+ ? {
+ id: user.id,
+ name: user.name || user.email || 'Anonymous',
+ username: user.profile?.username || undefined,
+ image: user.image || undefined,
+ isModerator: user.role === 'ADMIN',
+ }
+ : {
+ id: 'anonymous',
+ name: 'Anonymous',
+ username: undefined,
+ image: undefined,
+ isModerator: false,
+ };
+
+ return (
+
+
+
Discussion
+
+ Join the conversation about this hackathon
+
+
+
+
+
+ );
+}
diff --git a/components/hackathons/HackathonsList.tsx b/components/hackathons/HackathonsList.tsx
index e2f6c986..b92103f7 100644
--- a/components/hackathons/HackathonsList.tsx
+++ b/components/hackathons/HackathonsList.tsx
@@ -3,7 +3,6 @@
import React from 'react';
import HackathonCard from '@/components/landing-page/hackathon/HackathonCard';
import type { Hackathon } from '@/lib/api/hackathons';
-import { useHackathonTransform } from '@/hooks/hackathon/use-hackathon-transform';
interface HackathonsListProps {
hackathons: Hackathon[];
@@ -11,8 +10,6 @@ interface HackathonsListProps {
}
const HackathonsList = ({ hackathons, className }: HackathonsListProps) => {
- const { transformHackathonForCard } = useHackathonTransform();
-
if (hackathons.length === 0) {
return null;
}
@@ -21,18 +18,11 @@ const HackathonsList = ({ hackathons, className }: HackathonsListProps) => {
{hackathons.map(hackathon => {
- const orgName =
- '_organizationName' in hackathon
- ? (hackathon as Hackathon & { _organizationName?: string })
- ._organizationName
- : undefined;
- const transformed = transformHackathonForCard(hackathon, orgName);
return (
);
})}
diff --git a/components/hackathons/HackathonsPage.tsx b/components/hackathons/HackathonsPage.tsx
index c8ca8c4a..49d0a9c7 100644
--- a/components/hackathons/HackathonsPage.tsx
+++ b/components/hackathons/HackathonsPage.tsx
@@ -7,10 +7,8 @@ import LoadingSpinner from '@/components/LoadingSpinner';
import { useHackathonFilters } from '@/hooks/hackathon/use-hackathon-filters';
import { useHackathonsList } from '@/hooks/hackathon/use-hackathons-list';
import { useHackathonTransform } from '@/hooks/hackathon/use-hackathon-transform';
-import type { Hackathon } from '@/lib/api/hackathons';
import { BoundlessButton } from '../buttons';
-import { ArrowDownIcon, RefreshCwIcon, XIcon } from 'lucide-react';
-import EmptyState from '../EmptyState';
+import { ArrowDownIcon, XIcon } from 'lucide-react';
import LoadingScreen from '../landing-page/project/CreateProjectModal/LoadingScreen';
interface HackathonsPageProps {
@@ -39,21 +37,17 @@ export default function HackathonsPage({
hasMore,
totalCount,
loadMore,
- refetch,
} = useHackathonsList({ initialFilters: filters });
const { transformHackathonForCard } = useHackathonTransform();
-
const hackathonCards = React.useMemo(() => {
return hackathons.map(hackathon => {
- const orgName =
- '_organizationName' in hackathon
- ? (hackathon as Hackathon & { _organizationName?: string })
- ._organizationName
- : undefined;
- const cardData = transformHackathonForCard(hackathon, orgName);
return (
-
+
);
});
}, [hackathons, transformHackathonForCard]);
@@ -106,7 +100,7 @@ export default function HackathonsPage({
{error && (
)}
@@ -129,7 +123,7 @@ export default function HackathonsPage({
{!loading && !error && hackathons.length === 0 && (
-
+ /> */}
{(filters.search ||
filters.category ||
diff --git a/components/hackathons/discussion/comment.tsx b/components/hackathons/discussion/comment.tsx
index d586eab3..0865cca7 100644
--- a/components/hackathons/discussion/comment.tsx
+++ b/components/hackathons/discussion/comment.tsx
@@ -5,7 +5,9 @@ import { CommentsSortDropdown } from '@/components/project-details/comment-secti
import { CommentItem } from '@/components/project-details/comment-section/comment-item';
import { CommentInput } from '@/components/project-details/comment-section/comment-input';
import { CommentsEmptyState } from '@/components/project-details/comment-section/comments-empty-state';
-import { useDiscussions } from '@/hooks/hackathon/use-discussions-api';
+import { useCommentSystem } from '@/hooks/use-comment-system';
+import { CommentEntityType, Comment, ReportReason } from '@/types/comment';
+import { useAuth } from '@/hooks/use-auth';
import { Loader2 } from 'lucide-react';
interface HackathonDiscussionsProps {
@@ -13,9 +15,17 @@ interface HackathonDiscussionsProps {
organizationId?: string;
isRegistered?: boolean;
}
+
+// Adapter function to convert Comment to ProjectComment for CommentItem
+const commentToProjectComment = (comment: Comment): Comment => {
+ return {
+ ...comment,
+ replies: comment.replies?.map(commentToProjectComment),
+ };
+};
+
export function HackathonDiscussions({
hackathonId,
- organizationId,
isRegistered = false,
}: HackathonDiscussionsProps) {
const [sortBy, setSortBy] = useState<
@@ -23,30 +33,48 @@ export function HackathonDiscussions({
>('createdAt');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
+ const { user } = useAuth(false);
+
+ // Use the generic comment system
const {
- discussions,
- isLoading,
- isCreating,
- isUpdating,
- isDeleting,
- error,
- addDiscussion,
- addReply,
- updateDiscussion,
- deleteDiscussion,
- reportDiscussion,
- fetchDiscussions,
- } = useDiscussions({
- hackathonSlugOrId: hackathonId,
- organizationId,
- autoFetch: true,
- sortBy,
- sortOrder,
+ comments: commentsHook,
+ createComment: createCommentHook,
+ updateComment: updateCommentHook,
+ deleteComment: deleteCommentHook,
+ reportComment: reportCommentHook,
+ } = useCommentSystem({
+ entityType: CommentEntityType.HACKATHON,
+ entityId: hackathonId,
+ page: 1,
+ limit: 100,
+ enabled: true,
});
- // Sort discussions client-side (API may also sort, but we do it here for consistency)
- const sortedDiscussions = useMemo(() => {
- return [...discussions].sort((a, b) => {
+ // Build nested comment structure and sort
+ const sortedComments = useMemo(() => {
+ // Separate top-level comments and replies
+ const topLevelComments = commentsHook.comments.filter(
+ comment => !comment.parentId
+ );
+ const repliesMap = new Map();
+
+ // Group replies by parent ID
+ commentsHook.comments.forEach(comment => {
+ if (comment.parentId) {
+ const replies = repliesMap.get(comment.parentId) || [];
+ replies.push(comment);
+ repliesMap.set(comment.parentId, replies);
+ }
+ });
+
+ // Attach replies to parent comments
+ const commentsWithReplies = topLevelComments.map(comment => ({
+ ...comment,
+ replies: repliesMap.get(comment.id) || [],
+ }));
+
+ // Sort top-level comments
+ return commentsWithReplies.sort((a, b) => {
let comparison = 0;
if (sortBy === 'createdAt') {
comparison =
@@ -55,16 +83,20 @@ export function HackathonDiscussions({
comparison =
new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
} else {
- comparison = a.totalReactions - b.totalReactions;
+ comparison = (a.reactionCount || 0) - (b.reactionCount || 0);
}
return sortOrder === 'desc' ? -comparison : comparison;
});
- }, [discussions, sortBy, sortOrder]);
+ }, [commentsHook.comments, sortBy, sortOrder]);
- const handleAddDiscussion = async (content: string) => {
+ const handleAddComment = async (content: string) => {
try {
- await addDiscussion(content);
- // Discussions will be automatically updated by the hook
+ await createCommentHook.createComment({
+ content,
+ entityType: CommentEntityType.HACKATHON,
+ entityId: hackathonId,
+ });
+ commentsHook.refetch();
} catch {
// Error is already handled in the hook
}
@@ -72,44 +104,54 @@ export function HackathonDiscussions({
const handleAddReply = async (parentCommentId: string, content: string) => {
try {
- await addReply(parentCommentId, content);
- // Discussions will be automatically updated by the hook
+ await createCommentHook.createComment({
+ content,
+ entityType: CommentEntityType.HACKATHON,
+ entityId: hackathonId,
+ parentId: parentCommentId,
+ });
+ commentsHook.refetch();
} catch {
// Error is already handled in the hook
}
};
- const handleUpdateDiscussion = async (commentId: string, content: string) => {
+ const handleUpdateComment = async (commentId: string, content: string) => {
try {
- await updateDiscussion(commentId, content);
- // Discussions will be automatically updated by the hook
+ await updateCommentHook.updateComment(commentId, { content });
+ commentsHook.refetch();
} catch {
// Error is already handled in the hook
}
};
- const handleDeleteDiscussion = async (commentId: string) => {
+ const handleDeleteComment = async (commentId: string) => {
try {
- await deleteDiscussion(commentId);
- // Discussions will be automatically updated by the hook
+ await deleteCommentHook.deleteComment(commentId);
+ commentsHook.refetch();
} catch {
// Error is already handled in the hook
}
};
- const handleReportDiscussion = async (
+ const handleReportComment = async (
commentId: string,
reason: string,
description?: string
) => {
try {
- await reportDiscussion(commentId, reason, description);
+ // Convert string reason to ReportReason enum
+ const reportReason = reason.toUpperCase() as keyof typeof ReportReason;
+ await reportCommentHook.reportComment(commentId, {
+ reason: ReportReason[reportReason] || ReportReason.OTHER,
+ description,
+ });
} catch {
// Error is already handled in the hook
}
};
- const loading = isLoading && discussions.length === 0;
+ const loading = commentsHook.loading && commentsHook.comments.length === 0;
if (loading)
return (
@@ -119,12 +161,14 @@ export function HackathonDiscussions({
);
- if (error && discussions.length === 0)
+ if (commentsHook.error && commentsHook.comments.length === 0)
return (
-
Error loading discussions: {error}
+
+ Error loading discussions: {commentsHook.error}
+
);
- if (discussions.length === 0)
+ if (commentsHook.comments.length === 0)
return (
);
@@ -154,14 +198,15 @@ export function HackathonDiscussions({
- {sortedDiscussions.map(discussion => (
+ {sortedComments.map(comment => (
))}
@@ -169,11 +214,11 @@ export function HackathonDiscussions({
{isRegistered && (
-
+
)}
- {!isRegistered && discussions.length > 0 && (
+ {!isRegistered && commentsHook.comments.length > 0 && (
Register for this hackathon to join the discussion
@@ -181,20 +226,21 @@ export function HackathonDiscussions({
)}
- {error && discussions.length > 0 && (
+ {commentsHook.error && commentsHook.comments.length > 0 && (
-
{error}
+
{commentsHook.error}
)}
- {/* Loading state for discussion operations */}
- {(isCreating || isUpdating || isDeleting) && (
+ {(createCommentHook.loading ||
+ updateCommentHook.loading ||
+ deleteCommentHook.loading) && (
- {isCreating && 'Posting...'}
- {isUpdating && 'Updating...'}
- {isDeleting && 'Deleting...'}
+ {createCommentHook.loading && 'Posting...'}
+ {updateCommentHook.loading && 'Updating...'}
+ {deleteCommentHook.loading && 'Deleting...'}
)}
diff --git a/components/hackathons/hackathonBanner.tsx b/components/hackathons/hackathonBanner.tsx
index 66608363..5554031d 100644
--- a/components/hackathons/hackathonBanner.tsx
+++ b/components/hackathons/hackathonBanner.tsx
@@ -30,9 +30,9 @@ interface HackathonBannerProps {
isEnded?: boolean;
isTeamFormationEnabled?: boolean;
registrationDeadlinePolicy?:
- | 'before_start'
- | 'before_submission_deadline'
- | 'custom';
+ | 'BEFORE_START'
+ | 'BEFORE_SUBMISSION_DEADLINE'
+ | 'CUSTOM';
registrationDeadline?: string;
onJoinClick?: () => void;
onSubmitClick?: () => void;
@@ -73,20 +73,20 @@ export function HackathonBanner({
// Determine if registration is allowed
const canRegister = useMemo(() => {
const now = new Date();
- const policy = registrationDeadlinePolicy || 'before_submission_deadline';
+ const policy = registrationDeadlinePolicy || 'BEFORE_SUBMISSION_DEADLINE';
switch (policy) {
- case 'before_start':
+ case 'BEFORE_START':
if (startDate) {
return now < new Date(startDate);
}
return false;
- case 'before_submission_deadline':
+ case 'BEFORE_SUBMISSION_DEADLINE':
if (deadline) {
return now < new Date(deadline);
}
return false;
- case 'custom':
+ case 'CUSTOM':
if (registrationDeadline) {
return now < new Date(registrationDeadline);
}
@@ -103,12 +103,12 @@ export function HackathonBanner({
const now = new Date();
const isBeforeStart = startDate && now < new Date(startDate);
- switch (registrationDeadlinePolicy || 'before_submission_deadline') {
- case 'before_start':
+ switch (registrationDeadlinePolicy || 'BEFORE_SUBMISSION_DEADLINE') {
+ case 'BEFORE_START':
return 'Register Before Start';
- case 'before_submission_deadline':
+ case 'BEFORE_SUBMISSION_DEADLINE':
return isBeforeStart ? 'Early Register' : 'Join Hackathon';
- case 'custom':
+ case 'CUSTOM':
if (registrationDeadline) {
const customDeadline = new Date(registrationDeadline);
const isBeforeCustomDeadline = now < customDeadline;
@@ -235,7 +235,7 @@ export function HackathonBanner({
return (
-
+
{/* Wave Background */}
{/* Gradient overlay */}
-
+
{/* Status Badge */}
diff --git a/components/hackathons/hackathonStickyCard.tsx b/components/hackathons/hackathonStickyCard.tsx
index 964962d4..3ce1adfc 100644
--- a/components/hackathons/hackathonStickyCard.tsx
+++ b/components/hackathons/hackathonStickyCard.tsx
@@ -27,9 +27,9 @@ interface HackathonStickyCardProps {
hasSubmitted?: boolean;
isTeamFormationEnabled?: boolean;
registrationDeadlinePolicy?:
- | 'before_start'
- | 'before_submission_deadline'
- | 'custom';
+ | 'BEFORE_START'
+ | 'BEFORE_SUBMISSION_DEADLINE'
+ | 'CUSTOM';
registrationDeadline?: string;
isLeaving?: boolean;
onJoinClick?: () => void;
@@ -70,11 +70,11 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) {
const policy = registrationDeadlinePolicy || 'before_submission_deadline';
switch (policy) {
- case 'before_start':
+ case 'BEFORE_START':
return startDate ? now < new Date(startDate) : false;
- case 'before_submission_deadline':
+ case 'BEFORE_SUBMISSION_DEADLINE':
return deadline ? now < new Date(deadline) : false;
- case 'custom':
+ case 'CUSTOM':
return registrationDeadline
? now < new Date(registrationDeadline)
: false;
@@ -90,11 +90,11 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) {
const beforeStart = startDate && now < new Date(startDate);
switch (registrationDeadlinePolicy || 'before_submission_deadline') {
- case 'before_start':
+ case 'BEFORE_START':
return 'Register';
- case 'before_submission_deadline':
+ case 'BEFORE_SUBMISSION_DEADLINE':
return beforeStart ? 'Register' : 'Join';
- case 'custom':
+ case 'CUSTOM':
if (registrationDeadline) {
const custom = new Date(registrationDeadline);
const beforeCustom = now < custom;
@@ -133,7 +133,7 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) {
};
return (
-
+
{/* Image Section - UNCHANGED */}
{imageUrl && (
diff --git a/components/hackathons/overview/RegisterHackathonModal.tsx b/components/hackathons/overview/RegisterHackathonModal.tsx
index 8627a3ad..d21397f1 100644
--- a/components/hackathons/overview/RegisterHackathonModal.tsx
+++ b/components/hackathons/overview/RegisterHackathonModal.tsx
@@ -1,9 +1,5 @@
'use client';
-import { useState } from 'react';
-import { useForm } from 'react-hook-form';
-import { zodResolver } from '@hookform/resolvers/zod';
-import { z } from 'zod';
import {
Dialog,
DialogContent,
@@ -11,114 +7,40 @@ import {
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription,
-} from '@/components/ui/form';
-import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
-import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
-import { Label } from '@/components/ui/label';
-import { Users, User, Loader2 } from 'lucide-react';
+import { Loader2 } from 'lucide-react';
import { useRegisterHackathon } from '@/hooks/hackathon/use-register-hackathon';
import { toast } from 'sonner';
-
-const registerSchema = z
- .object({
- participationType: z.enum(['individual', 'team']),
- teamName: z.string().optional(),
- teamMembers: z.array(z.string()).optional(),
- })
- .refine(
- data => {
- if (data.participationType === 'team') {
- return data.teamName && data.teamName.trim().length > 0;
- }
- return true;
- },
- {
- message: 'Team name is required for team participation',
- path: ['teamName'],
- }
- );
-
-type RegisterFormData = z.infer;
+import type { Hackathon } from '@/lib/api/hackathons';
interface RegisterHackathonModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
- hackathonSlugOrId: string;
+ hackathon: Hackathon | null;
organizationId?: string;
- participantType: 'individual' | 'team' | 'team_or_individual';
onSuccess?: (participantData?: any) => void;
}
export function RegisterHackathonModal({
open,
onOpenChange,
- hackathonSlugOrId,
+ hackathon,
organizationId,
- participantType,
onSuccess,
}: RegisterHackathonModalProps) {
- const [teamMemberEmail, setTeamMemberEmail] = useState('');
- const [teamMembers, setTeamMembers] = useState([]);
-
const { register: registerForHackathon, isRegistering } =
useRegisterHackathon({
- hackathonSlugOrId,
+ hackathon,
organizationId,
autoCheck: false,
});
- const form = useForm({
- resolver: zodResolver(registerSchema),
- defaultValues: {
- participationType: participantType === 'team' ? 'team' : 'individual',
- teamName: '',
- teamMembers: [],
- },
- });
-
- const selectedType = form.watch('participationType');
-
- const handleAddTeamMember = () => {
- if (
- teamMemberEmail.trim() &&
- !teamMembers.includes(teamMemberEmail.trim())
- ) {
- const updated = [...teamMembers, teamMemberEmail.trim()];
- setTeamMembers(updated);
- form.setValue('teamMembers', updated);
- setTeamMemberEmail('');
- }
- };
-
- const handleRemoveTeamMember = (email: string) => {
- const updated = teamMembers.filter(m => m !== email);
- setTeamMembers(updated);
- form.setValue('teamMembers', updated);
- };
-
- const onSubmit = async (data: RegisterFormData) => {
+ const handleRegister = async () => {
try {
- const participantData = await registerForHackathon({
- participationType: data.participationType,
- teamName: data.participationType === 'team' ? data.teamName : undefined,
- teamMembers:
- data.participationType === 'team' ? data.teamMembers : undefined,
- });
+ const participantData = await registerForHackathon();
toast.success('Successfully registered for hackathon!');
onOpenChange(false);
- form.reset();
- setTeamMembers([]);
- setTeamMemberEmail('');
// Pass the participant data to onSuccess
onSuccess?.(participantData);
@@ -134,182 +56,36 @@ export function RegisterHackathonModal({
Register for Hackathon
- {participantType === 'team_or_individual' && (
-
- Choose how you want to participate in this hackathon
-
- )}
- {participantType === 'individual' && (
-
- Register to participate in this hackathon
-
- )}
- {participantType === 'team' && (
-
- Register your team for this hackathon
-
- )}
+
+ Register to participate in this hackathon
+
-
- {prize.amount} {prize.currency || 'USDC'}
+ {prize.prizeAmount} {prize.currency || 'USDC'}
@@ -143,10 +143,10 @@ export function HackathonPrizes({
className='border-b border-white/10 transition-colors hover:bg-white/5'
>
- {prize.position}
+ {prize.place}
|
- {prize.amount}
+ {prize.prizeAmount}
|
{prize.currency || 'USDC'}
diff --git a/components/hackathons/participants/hackathonParticipant.tsx b/components/hackathons/participants/hackathonParticipant.tsx
index 2f7a166d..e8f5c6ce 100644
--- a/components/hackathons/participants/hackathonParticipant.tsx
+++ b/components/hackathons/participants/hackathonParticipant.tsx
@@ -23,7 +23,7 @@ export const HackathonParticipants = () => {
Understanding Participant Status
-
+
diff --git a/components/hackathons/participants/participantAvatar.tsx b/components/hackathons/participants/participantAvatar.tsx
index 341c1740..50ee8b95 100644
--- a/components/hackathons/participants/participantAvatar.tsx
+++ b/components/hackathons/participants/participantAvatar.tsx
@@ -8,11 +8,11 @@ import {
} from '@/components/ui/tooltip';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { ProfileCard } from './profileCard';
-import type { Participant } from '@/types/hackathon';
+import type { ParticipantDisplay } from '@/lib/api/hackathons/index';
import Image from 'next/image';
interface ParticipantAvatarProps {
- participant: Participant;
+ participant: ParticipantDisplay;
}
export function ParticipantAvatar({ participant }: ParticipantAvatarProps) {
diff --git a/components/hackathons/participants/profileCard.tsx b/components/hackathons/participants/profileCard.tsx
index 1e6c6231..3e67dcf8 100644
--- a/components/hackathons/participants/profileCard.tsx
+++ b/components/hackathons/participants/profileCard.tsx
@@ -5,16 +5,16 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator';
-import type { Participant } from '@/types/hackathon';
+import type { ParticipantDisplay } from '@/lib/api/hackathons/index';
import Image from 'next/image';
import { MessageCircle, Users, CheckCircle2 } from 'lucide-react';
-import { useHackathonData } from '@/lib/providers/hackathonProvider';
+import { useParticipants } from '@/hooks/hackathon/use-participants';
import Link from 'next/link';
const BRAND_COLOR = '#a7f950';
interface ProfileCardProps {
- participant: Participant;
+ participant: ParticipantDisplay;
}
// Simple date formatter
@@ -39,7 +39,7 @@ const formatJoinDate = (dateString: string) => {
export function ProfileCard({ participant }: ProfileCardProps) {
const [isFollowing, setIsFollowing] = useState(false);
- const { participants } = useHackathonData();
+ const { participants } = useParticipants();
const teamMembers = useMemo(() => {
if (participant.role === 'leader' && participant.teamId) {
return participants.filter(
diff --git a/components/hackathons/resources/resources.tsx b/components/hackathons/resources/resources.tsx
index 4ece5136..ade19cac 100644
--- a/components/hackathons/resources/resources.tsx
+++ b/components/hackathons/resources/resources.tsx
@@ -11,7 +11,6 @@ import {
} from 'lucide-react';
import Link from 'next/link';
import { useHackathonData } from '@/lib/providers/hackathonProvider';
-import type { HackathonResourceDocument as HackathonResource } from '@/lib/api/hackathons';
import {
VideoPlayer,
VideoPlayerContent,
@@ -25,6 +24,18 @@ import {
VideoPlayerVolumeRange,
} from '@/components/ui/video-player';
+interface ResourceDisplay {
+ id: string;
+ title: string;
+ type: 'pdf' | 'doc' | 'sheet' | 'slide' | 'link' | 'video';
+ url: string;
+ size: undefined;
+ description: string;
+ uploadDate: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
// interface HackathonResourcesProps {
// hackathonSlugOrId?: string;
// organizationId?: string;
@@ -34,19 +45,19 @@ export function HackathonResources() {
const { currentHackathon } = useHackathonData();
// Transform resources from hackathon data to component format
- const resources: HackathonResource[] = useMemo(() => {
- if (!currentHackathon?.resources?.resources) {
+ const resources: ResourceDisplay[] = useMemo(() => {
+ if (!currentHackathon?.resources) {
return [];
}
- return currentHackathon.resources.resources.map((resource, index) => {
- const url = resource.fileUrl || resource.link || '';
- const fileName = resource.fileName || '';
+ return currentHackathon.resources.map((resource, index) => {
+ const url = resource.file?.url || resource.link || '';
+ const fileName = resource.file?.name || '';
// Determine resource type based on URL or file extension
let type: 'pdf' | 'doc' | 'sheet' | 'slide' | 'link' | 'video' = 'link';
- if (resource.fileUrl) {
+ if (resource.file?.url) {
const extension = fileName.toLowerCase().split('.').pop();
if (extension === 'pdf') type = 'pdf';
else if (extension === 'doc' || extension === 'docx') type = 'doc';
@@ -84,7 +95,7 @@ export function HackathonResources() {
}
return {
- _id: `resource-${index}`,
+ id: `resource-${index}`,
title: resource.description || fileName || `Resource ${index + 1}`,
type,
url,
@@ -214,7 +225,7 @@ export function HackathonResources() {
return (
{isEmbed ? (
@@ -232,7 +243,7 @@ export function HackathonResources() {
className='h-full w-full object-contain'
controls
/>
-
+
@@ -267,14 +278,14 @@ export function HackathonResources() {
{documentResources.map(resource => (
-
+
{getFileIcon(resource.type)}
diff --git a/components/hackathons/submissions/CreateSubmissionModal.tsx b/components/hackathons/submissions/CreateSubmissionModal.tsx
index 11681104..fe248e77 100644
--- a/components/hackathons/submissions/CreateSubmissionModal.tsx
+++ b/components/hackathons/submissions/CreateSubmissionModal.tsx
@@ -26,7 +26,10 @@ import {
import BoundlessSheet from '@/components/sheet/boundless-sheet';
import Stepper from '@/components/stepper/Stepper';
import { uploadService } from '@/lib/api/upload';
-import { useSubmission } from '@/hooks/hackathon/use-submission';
+import {
+ useSubmission,
+ type SubmissionFormData,
+} from '@/hooks/hackathon/use-submission';
import { toast } from 'sonner';
import { Loader2, Upload, X, Link2, Plus } from 'lucide-react';
import Image from 'next/image';
@@ -48,24 +51,23 @@ const submissionSchema = z.object({
.union([z.string().url('Please enter a valid URL'), z.literal('')])
.optional(),
introduction: z.string().optional(),
- links: z
- .array(
- z.object({
- type: z.string(),
- url: z.string().url('Please enter a valid URL'),
- })
- )
- .optional(),
+ links: z.array(
+ z.object({
+ type: z.string(),
+ url: z.string().url('Please enter a valid URL'),
+ })
+ ),
+ participationType: z.enum(['INDIVIDUAL', 'TEAM']),
});
-type SubmissionFormData = z.infer ;
+type SubmissionFormDataLocal = z.infer;
interface CreateSubmissionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
hackathonSlugOrId: string;
organizationId?: string;
- initialData?: Partial;
+ initialData?: Partial;
submissionId?: string;
onSuccess?: () => void;
}
@@ -138,7 +140,7 @@ export function CreateSubmissionModal({
autoFetch: false,
});
- const form = useForm({
+ const form = useForm({
resolver: zodResolver(submissionSchema),
mode: 'onChange',
defaultValues: {
@@ -149,6 +151,7 @@ export function CreateSubmissionModal({
videoUrl: '',
introduction: '',
links: [],
+ participationType: 'INDIVIDUAL',
},
});
@@ -166,6 +169,7 @@ export function CreateSubmissionModal({
videoUrl: initialData.videoUrl || '',
introduction: initialData.introduction || '',
links: initialData.links || [],
+ participationType: initialData.participationType || 'INDIVIDUAL',
});
if (initialData.logo && isValidImageUrl(initialData.logo)) {
setLogoPreview(initialData.logo);
@@ -186,6 +190,7 @@ export function CreateSubmissionModal({
videoUrl: '',
introduction: '',
links: [],
+ participationType: 'INDIVIDUAL',
});
setLogoPreview('');
setCurrentStep(0);
@@ -262,6 +267,7 @@ export function CreateSubmissionModal({
{ type: 'demo', url: 'https://demo.example.com/ai-task-manager' },
{ type: 'website', url: 'https://www.example.com/ai-task-manager' },
],
+ participationType: 'INDIVIDUAL' as const,
};
form.reset(mockData);
@@ -349,7 +355,7 @@ export function CreateSubmissionModal({
}
};
- const onSubmit = async (data: SubmissionFormData) => {
+ const onSubmit = async (data: SubmissionFormDataLocal) => {
try {
// Use the data parameter directly (it's already validated by the form)
// Get current form values as fallback
@@ -357,7 +363,7 @@ export function CreateSubmissionModal({
// Ensure all required fields are strings (never undefined)
// Use data parameter first, then fallback to currentValues
- const safeData = {
+ const safeData: SubmissionFormData = {
projectName: (data.projectName ?? currentValues.projectName ?? '')
.toString()
.trim(),
@@ -394,7 +400,8 @@ export function CreateSubmissionModal({
type: link.type.toString(),
url: link.url.toString().trim(),
}))
- : undefined,
+ : [],
+ participationType: data.participationType || 'INDIVIDUAL',
};
// Validate all required fields one more time before submission
@@ -416,14 +423,15 @@ export function CreateSubmissionModal({
}
// Clean and prepare submission data
- const submissionData = {
+ const submissionData: SubmissionFormData = {
projectName: safeData.projectName,
category: safeData.category,
description: safeData.description,
logo: safeData.logo,
videoUrl: safeData.videoUrl,
introduction: safeData.introduction,
- links: safeData.links,
+ links: safeData.links || [],
+ participationType: safeData.participationType || 'INDIVIDUAL',
};
if (submissionId) {
diff --git a/components/hackathons/submissions/SubmissionDetailModal.tsx b/components/hackathons/submissions/SubmissionDetailModal.tsx
index f1b77ab2..2c3aa81a 100644
--- a/components/hackathons/submissions/SubmissionDetailModal.tsx
+++ b/components/hackathons/submissions/SubmissionDetailModal.tsx
@@ -64,11 +64,7 @@ export function SubmissionDetailModal({
const fetchSubmissionDetails = async () => {
setIsLoading(true);
try {
- const response = await getSubmissionDetails(
- hackathonSlugOrId,
- submissionId,
- organizationId
- );
+ const response = await getSubmissionDetails(submissionId);
if (response.success && response.data) {
setSubmission(response.data);
// Check if user has voted
diff --git a/components/hackathons/submissions/submissionTab.tsx b/components/hackathons/submissions/submissionTab.tsx
index 7d4f8c8d..d0b916a0 100644
--- a/components/hackathons/submissions/submissionTab.tsx
+++ b/components/hackathons/submissions/submissionTab.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState } from 'react';
+import React, { useState } from 'react';
import { Search, ChevronDown, Plus, Edit } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -17,27 +17,23 @@ import { useSubmissions } from '@/hooks/hackathon/use-submissions';
import { useSubmission } from '@/hooks/hackathon/use-submission';
import { useHackathonData } from '@/lib/providers/hackathonProvider';
import { useAuthStatus } from '@/hooks/use-auth';
-import { useParams } from 'next/navigation';
+// import { useParams } from 'next/navigation';
interface SubmissionTabProps {
- hackathonSlugOrId?: string;
+ // hackathonSlugOrId?: string;
organizationId?: string;
isRegistered: boolean;
}
const SubmissionTab: React.FC = ({
- hackathonSlugOrId,
+ // hackathonSlugOrId,
organizationId,
isRegistered,
}) => {
- const params = useParams();
+ // const params = useParams();
const { isAuthenticated } = useAuthStatus();
const { currentHackathon } = useHackathonData();
- const hackathonId =
- hackathonSlugOrId ||
- (params.slug as string) ||
- currentHackathon?.slug ||
- '';
+ const hackathonId = currentHackathon?.id || '';
const orgId = organizationId || undefined;
const {
@@ -319,7 +315,7 @@ const SubmissionTab: React.FC = ({
}
: undefined
}
- submissionId={mySubmission?._id}
+ submissionId={mySubmission?.id}
onSuccess={() => {
fetchMySubmission();
}}
diff --git a/components/hackathons/team-formation/CreateTeamPostModal.tsx b/components/hackathons/team-formation/CreateTeamPostModal.tsx
index f9ed984a..a8a75b59 100644
--- a/components/hackathons/team-formation/CreateTeamPostModal.tsx
+++ b/components/hackathons/team-formation/CreateTeamPostModal.tsx
@@ -205,7 +205,7 @@ export function CreateTeamPostModal({
}
if (isEditMode && initialData) {
- await updatePost(initialData._id, data);
+ await updatePost(initialData.id, data);
toast.success('Team post updated successfully');
} else {
await createPost(data);
diff --git a/components/hackathons/team-formation/TeamFormationTab.tsx b/components/hackathons/team-formation/TeamFormationTab.tsx
index 7c3aad3a..276e1848 100644
--- a/components/hackathons/team-formation/TeamFormationTab.tsx
+++ b/components/hackathons/team-formation/TeamFormationTab.tsx
@@ -88,8 +88,8 @@ export function TeamFormationTab({
const searchLower = searchTerm.toLowerCase();
filtered = filtered.filter(
post =>
- post.projectName.toLowerCase().includes(searchLower) ||
- post.projectDescription.toLowerCase().includes(searchLower) ||
+ post.projectName?.toLowerCase().includes(searchLower) ||
+ post.projectDescription?.toLowerCase().includes(searchLower) ||
post.lookingFor.some(role =>
role.role.toLowerCase().includes(searchLower)
)
@@ -117,8 +117,8 @@ export function TeamFormationTab({
if (searchTerm) {
const searchLower = searchTerm.toLowerCase();
return (
- post.projectName.toLowerCase().includes(searchLower) ||
- post.projectDescription.toLowerCase().includes(searchLower)
+ post.projectName?.toLowerCase().includes(searchLower) ||
+ post.projectDescription?.toLowerCase().includes(searchLower)
);
}
return true;
@@ -141,7 +141,7 @@ export function TeamFormationTab({
const handleConfirmDelete = async () => {
if (deletingPost) {
try {
- await deletePost(deletingPost._id);
+ await deletePost(deletingPost.id);
setDeletingPost(null);
} catch {
// Error handled in hook
@@ -150,7 +150,7 @@ export function TeamFormationTab({
};
const handleContactClick = (post: TeamRecruitmentPost) => {
- trackContact(post._id);
+ trackContact(post.id);
};
const activePostsCount = posts.filter(p => p.status === 'active').length;
@@ -215,7 +215,7 @@ export function TeamFormationTab({
{filteredMyPosts.map(post => (
{filteredPosts.map(post => (
p._id === post._id)}
+ isMyPost={myPosts.some(p => p.id === post.id)}
onContactClick={handleContactClick}
onEditClick={handleEditClick}
onDeleteClick={handleDeleteClick}
diff --git a/components/hackathons/team-formation/TeamRecruitmentPostCard.tsx b/components/hackathons/team-formation/TeamRecruitmentPostCard.tsx
index 1cae8d4f..7da805eb 100644
--- a/components/hackathons/team-formation/TeamRecruitmentPostCard.tsx
+++ b/components/hackathons/team-formation/TeamRecruitmentPostCard.tsx
@@ -151,12 +151,14 @@ export function TeamRecruitmentPostCard({
if (onContactClick) {
onContactClick(post);
}
- handleContact(
- post.contactMethod,
- post.contactInfo,
- onTrackContact,
- post._id
- );
+ if (post.contactMethod && post.contactInfo) {
+ handleContact(
+ post.contactMethod,
+ post.contactInfo,
+ onTrackContact,
+ post.id
+ );
+ }
};
const handleEditClick = (e: React.MouseEvent) => {
@@ -177,8 +179,6 @@ export function TeamRecruitmentPostCard({
switch (status) {
case 'active':
return 'border-[#A7F950] bg-[#A7F950]/10 text-[#A7F950]';
- case 'filled':
- return 'border-blue-500 bg-blue-500/10 text-blue-500';
case 'closed':
return 'border-gray-500 bg-gray-500/10 text-gray-500';
default:
@@ -193,20 +193,20 @@ export function TeamRecruitmentPostCard({
- {post.createdBy.name.slice(0, 2).toUpperCase()}
+ {(post.createdBy?.name || 'U').slice(0, 2).toUpperCase()}
- {post.createdBy.name}
+ {post.createdBy?.name}
- @{post.createdBy.username}
+ @{post.createdBy?.username}
diff --git a/components/landing-page/Explore.tsx b/components/landing-page/Explore.tsx
index 7aa93333..2505b86b 100644
--- a/components/landing-page/Explore.tsx
+++ b/components/landing-page/Explore.tsx
@@ -3,71 +3,33 @@
import { cn } from '@/lib/utils';
import { ArrowRight } from 'lucide-react';
import Image from 'next/image';
-import { useState, useRef, useEffect, useCallback } from 'react';
+import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import ProjectCard from './project/ProjectCard';
import HackathonCard from './hackathon/HackathonCard';
import Link from 'next/link';
-import { getProjects } from '@/lib/api/project';
-import {
- getPublicHackathonsList,
- transformPublicHackathonToHackathon,
-} from '@/lib/api/hackathons';
-import { useHackathonTransform } from '@/hooks/hackathon/use-hackathon-transform';
+import { getPublicHackathonsList } from '@/lib/api/hackathons';
import type { Hackathon } from '@/lib/api/hackathons';
import { Skeleton } from '@/components/ui/skeleton';
+import { useProjects } from '@/hooks/project/use-project';
-interface ProjectApi {
- _id: string;
- title: string;
- description: string;
- whitepaperUrl?: string;
- tags?: string[];
- category?: string;
- type?: string;
- status: string;
- createdAt: string;
- updatedAt: string;
- owner?: {
- type?: {
- _id?: string;
- profile?: {
- firstName?: string;
- lastName?: string;
- username?: string;
- avatar?: string;
- } | null;
- } | null;
- } | null;
-}
-
-// Map project status to ProjectCard status format
-const mapProjectStatus = (
+const mapCrowdfundingStatus = (
status: string
): 'Validation' | 'Funding' | 'Funded' | 'Completed' => {
- switch (status) {
- case 'under_review':
+ switch (status?.toLowerCase()) {
+ case 'validation':
return 'Validation';
+ case 'campaigning':
case 'funding':
return 'Funding';
case 'funded':
return 'Funded';
case 'completed':
return 'Completed';
- case 'in_progress':
- return 'Funding';
default:
return 'Validation';
}
};
-// Calculate days to deadline from project dates
-const calculateDaysToDeadline = (): number => {
- // For now, return a default value since we don't have deadline info in the basic project API
- // This would need to be enhanced when we have access to crowdfunding project details
- return 30; // Default placeholder
-};
-
-// Project Card Skeleton
const ProjectCardSkeleton = () => (
@@ -98,10 +60,8 @@ const ProjectCardSkeleton = () => (
);
-// Hackathon Card Skeleton
const HackathonCardSkeleton = () => (
- {/* Image */}
@@ -113,7 +73,6 @@ const HackathonCardSkeleton = () => (
- {/* Body */}
@@ -144,98 +103,62 @@ export default function Explore() {
const [underlineStyle, setUnderlineStyle] = useState({});
const tabRefs = useRef >({});
- const [projects, setProjects] = useState([]);
- const [projectsLoading, setProjectsLoading] = useState(false);
- const [projectsError, setProjectsError] = useState(null);
- const [projectsFetched, setProjectsFetched] = useState(false);
+ const initialFilters = useMemo(() => ({}), []);
+
+ const {
+ projects,
+ loading: projectsLoading,
+ error: projectsError,
+ } = useProjects({
+ initialPage: 1,
+ pageSize: 6,
+ initialFilters,
+ });
const [hackathons, setHackathons] = useState([]);
const [hackathonsLoading, setHackathonsLoading] = useState(false);
const [hackathonsError, setHackathonsError] = useState(null);
const [hackathonsFetched, setHackathonsFetched] = useState(false);
- const { transformHackathonForCard } = useHackathonTransform();
-
- const fetchProjects = useCallback(async () => {
- if (projectsFetched) return; // Prevent refetching if already fetched
-
- try {
- setProjectsLoading(true);
- setProjectsError(null);
- const response = await getProjects(1, 6);
- const apiProjects = (response.projects ?? []) as unknown as ProjectApi[];
- setProjects(apiProjects);
- setProjectsFetched(true); // Mark as fetched even if empty
- } catch {
- setProjectsError('Failed to fetch projects');
- setProjectsFetched(true); // Mark as fetched even on error to prevent retries
- } finally {
- setProjectsLoading(false);
- }
- }, [projectsFetched]);
-
- // Fetch hackathons
const fetchHackathons = useCallback(async () => {
- if (hackathonsFetched) return; // Prevent refetching if already fetched
+ if (hackathonsFetched) return;
try {
setHackathonsLoading(true);
setHackathonsError(null);
+
const response = await getPublicHackathonsList({
- status: 'ongoing',
+ status: 'active',
limit: 6,
page: 1,
});
- // Transform public hackathons to Hackathon type
- const hackathonsList = response.data.hackathons || [];
- const transformedHackathons = hackathonsList.map(hackathon =>
- transformPublicHackathonToHackathon(hackathon)
- );
-
- setHackathons(transformedHackathons);
- setHackathonsFetched(true); // Mark as fetched even if empty
+ const hackathonsList = response.hackathons || [];
+ setHackathons(hackathonsList);
+ setHackathonsFetched(true);
} catch {
setHackathonsError('Failed to fetch hackathons');
- setHackathonsFetched(true); // Mark as fetched even on error to prevent retries
+ setHackathonsFetched(true);
} finally {
setHackathonsLoading(false);
}
}, [hackathonsFetched]);
- // Reset fetched flags when switching to a tab (allows fresh fetch)
useEffect(() => {
- if (activeTab === 'featured-projects') {
- setProjectsFetched(false);
- } else if (activeTab === 'ongoing-hackathons') {
+ if (activeTab === 'ongoing-hackathons') {
setHackathonsFetched(false);
}
}, [activeTab]);
- // Fetch data when tab changes and hasn't been fetched yet
useEffect(() => {
if (
- activeTab === 'featured-projects' &&
- !projectsFetched &&
- !projectsLoading
- ) {
- fetchProjects();
- } else if (
activeTab === 'ongoing-hackathons' &&
!hackathonsFetched &&
!hackathonsLoading
) {
fetchHackathons();
}
- }, [
- activeTab,
- fetchProjects,
- fetchHackathons,
- projectsFetched,
- hackathonsFetched,
- projectsLoading,
- hackathonsLoading,
- ]);
+ }, [activeTab, fetchHackathons, hackathonsFetched, hackathonsLoading]);
useEffect(() => {
const currentTab = tabRefs.current[activeTab];
@@ -300,26 +223,28 @@ export default function Explore() {
) : (
projects.map(project => {
- const ownerName = project.owner?.type?.profile
- ? `${project.owner.type.profile.firstName || ''} ${project.owner.type.profile.lastName || ''}`.trim() ||
- 'Anonymous'
- : 'Anonymous';
- const ownerAvatar =
- project.owner?.type?.profile?.avatar || '/avatar.png';
- const projectImage = project.whitepaperUrl || '/banner.png';
+ const daysToDeadline = project.fundingEndDate
+ ? Math.ceil(
+ (new Date(project.fundingEndDate).getTime() -
+ Date.now()) /
+ (1000 * 60 * 60 * 24)
+ )
+ : 30;
return (
);
})
@@ -344,24 +269,13 @@ export default function Explore() {
No ongoing hackathons at the moment.
) : (
- hackathons.map(hackathon => {
- const orgName =
- '_organizationName' in hackathon
- ? (hackathon as Hackathon & { _organizationName?: string })
- ._organizationName
- : undefined;
- const transformed = transformHackathonForCard(
- hackathon,
- orgName
- );
- return (
-
- );
- })
+ hackathons.map(hackathon => (
+
+ ))
)}
>
)}
@@ -388,7 +302,6 @@ export default function Explore() {
- {/* Glow Effects */}
Launch Projects
@@ -118,22 +118,100 @@ export default function Hero2() {
@@ -168,22 +246,100 @@ export default function Hero2() {
diff --git a/components/landing-page/WhyBoundless.tsx b/components/landing-page/WhyBoundless.tsx
index cd98dab8..0d6b21a2 100644
--- a/components/landing-page/WhyBoundless.tsx
+++ b/components/landing-page/WhyBoundless.tsx
@@ -2,7 +2,7 @@
import { FileLock, Globe, UserCheck } from 'lucide-react';
import Image from 'next/image';
-import { useState } from 'react';
+import React, { useState } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
interface FeatureCardProps {
@@ -139,6 +139,7 @@ const WhyBoundless = () => {
width={800}
height={600}
priority
+ unoptimized
className={`z-30 mx-auto h-auto w-full max-w-[754px] rounded-xl object-cover transition-opacity duration-300 ${
gifLoading ? 'opacity-0' : 'opacity-100'
}`}
diff --git a/components/landing-page/blog/BlogCard.tsx b/components/landing-page/blog/BlogCard.tsx
index 5fe89402..2caeb828 100644
--- a/components/landing-page/blog/BlogCard.tsx
+++ b/components/landing-page/blog/BlogCard.tsx
@@ -4,10 +4,10 @@ import {
CardFooter,
CardHeader,
} from '@/components/ui/card';
-import Link from 'next/link';
import Image from 'next/image';
-import React from 'react';
import { BlogPost } from '@/types/blog';
+import { Badge } from '@/components/ui/badge';
+import { ArrowRight, Clock } from 'lucide-react';
interface BlogCardProps {
post: BlogPost;
@@ -18,50 +18,87 @@ const BlogCard = ({ post, onCardClick }: BlogCardProps) => {
return (
-
-
+ {/* Image Header with 2:1 Aspect Ratio */}
+
+
+ {/* Gradient Overlay */}
+
-
-
-
- {post.category}
+ {/* Content Section */}
+
+ {/* Meta Information */}
+
+ {/* Categories */}
+
+ {post.categories?.slice(0, 2).map(category => (
+
+ {category}
+
+ ))}
+ {post.categories && post.categories.length > 2 && (
+
+ +{post.categories.length - 2}
+
+ )}
+
+
+ {/* Date */}
+
+ {post.publishedAt}
- {post.date}
-
+
+ {/* Title */}
+
{post.title}
-
+
+ {/* Excerpt */}
+
{post.excerpt}
+
+ {/* Reading Time (if available) */}
+ {post.readingTime && (
+
+
+ {post.readingTime} min read
+
+ )}
-
- onCardClick?.(post.slug)}
+ {/* Footer with CTA */}
+
+
);
diff --git a/components/landing-page/blog/BlogGrid.tsx b/components/landing-page/blog/BlogGrid.tsx
index 414868f8..ab00de40 100644
--- a/components/landing-page/blog/BlogGrid.tsx
+++ b/components/landing-page/blog/BlogGrid.tsx
@@ -50,8 +50,10 @@ const BlogGrid: React.FC = ({
// Filter by categories
if (selectedCategories.length > 0) {
- filtered = filtered.filter(post =>
- selectedCategories.includes(post.category)
+ filtered = filtered.filter(
+ post =>
+ post.categories &&
+ post.categories.some(cat => selectedCategories.includes(cat))
);
}
@@ -62,15 +64,16 @@ const BlogGrid: React.FC = ({
post =>
post.title.toLowerCase().includes(query) ||
post.excerpt.toLowerCase().includes(query) ||
- post.tags.some(tag => tag.toLowerCase().includes(query))
+ (post.tags &&
+ post.tags.some(tag => tag.tag.name.toLowerCase().includes(query)))
);
}
// Sort posts
if (sortOrder) {
filtered = [...filtered].sort((a, b) => {
- const dateA = new Date(a.publishedAt).getTime();
- const dateB = new Date(b.publishedAt).getTime();
+ const dateA = new Date(a.createdAt).getTime();
+ const dateB = new Date(b.createdAt).getTime();
return sortOrder === 'Latest' ? dateB - dateA : dateA - dateB;
});
}
diff --git a/components/landing-page/blog/BlogPostDetails.tsx b/components/landing-page/blog/BlogPostDetails.tsx
index 88def9ec..d538c7db 100644
--- a/components/landing-page/blog/BlogPostDetails.tsx
+++ b/components/landing-page/blog/BlogPostDetails.tsx
@@ -4,12 +4,12 @@ import React, { useState, useEffect } from 'react';
import Image from 'next/image';
import { Tag, BookOpen, Check } from 'lucide-react';
import { BlogPost } from '@/types/blog';
-import { getRelatedPosts } from '@/lib/api/blog';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { useMarkdown } from '@/hooks/use-markdown';
import BlogCard from './BlogCard';
import AuthLoadingState from '@/components/auth/AuthLoadingState';
+import { getRelatedPosts } from '@/lib/api/blog';
interface BlogPostDetailsProps {
post: BlogPost;
@@ -36,8 +36,8 @@ const BlogPostDetails: React.FC = ({ post }) => {
try {
setIsLoadingRelated(true);
setRelatedPostsError(null);
- const related = await getRelatedPosts(post.slug, { limit: 3 });
- setRelatedPosts(related || []);
+ const related = await getRelatedPosts(post.id);
+ setRelatedPosts(related.posts);
} catch {
setRelatedPostsError('Failed to load related posts');
setRelatedPosts([]);
@@ -127,7 +127,7 @@ const BlogPostDetails: React.FC = ({ post }) => {
@@ -139,7 +139,7 @@ const BlogPostDetails: React.FC = ({ post }) => {
- {formatDate(post.publishedAt)}
+ {formatDate(post.createdAt)}
@@ -157,7 +157,7 @@ const BlogPostDetails: React.FC = ({ post }) => {
= ({ post }) => {
Tags:
- {post.tags.map(tag => (
+ {post.tags?.map(tag => (
- {tag}
+ {tag.tag.name}
))}
diff --git a/components/landing-page/blog/BlogSection.tsx b/components/landing-page/blog/BlogSection.tsx
index ae0907b7..26d38ff9 100644
--- a/components/landing-page/blog/BlogSection.tsx
+++ b/components/landing-page/blog/BlogSection.tsx
@@ -1,10 +1,20 @@
-import { getAllBlogPosts } from '@/lib/data/blog';
import BlogSectionClient from './BlogSectionClient';
+import { getBlogPosts } from '@/lib/api/blog';
const BlogSection = async () => {
- const posts = await getAllBlogPosts();
+ try {
+ const response = await getBlogPosts({
+ page: 1,
+ limit: 6,
+ sortBy: 'createdAt',
+ sortOrder: 'desc',
+ status: 'PUBLISHED',
+ });
- return ;
+ return ;
+ } catch {
+ return ;
+ }
};
export default BlogSection;
diff --git a/components/landing-page/blog/BlogSectionClient.tsx b/components/landing-page/blog/BlogSectionClient.tsx
index 3ff10e6f..ccb8fac8 100644
--- a/components/landing-page/blog/BlogSectionClient.tsx
+++ b/components/landing-page/blog/BlogSectionClient.tsx
@@ -2,32 +2,36 @@
import { ArrowRight } from 'lucide-react';
import Link from 'next/link';
-import { useCallback, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { useCallback, useState, useTransition } from 'react';
import AuthLoadingState from '@/components/auth/AuthLoadingState';
-import { BlogPost } from '@/lib/data/blog';
import BlogCard from './BlogCard';
+import { BlogPost } from '@/types/blog';
interface BlogSectionClientProps {
posts: BlogPost[];
}
const BlogSectionClient = ({ posts }: BlogSectionClientProps) => {
+ const router = useRouter();
+ const [isPending, startTransition] = useTransition();
const [isNavigating, setIsNavigating] = useState(false);
- const handleCardClick = useCallback((slug: string) => {
- setIsNavigating(true);
- // The navigation will be handled by Next.js Link, but we show loading state
- // The loading state will be cleared when the page actually navigates
- // eslint-disable-next-line no-console
- console.log(`Navigating to blog post: ${slug}`);
- setTimeout(() => {
- setIsNavigating(false);
- }, 5000);
- }, []);
+ const handleCardClick = useCallback(
+ (slug: string) => {
+ setIsNavigating(true);
+ startTransition(() => {
+ router.push(`/blog/${slug}`);
+ });
+ },
+ [router]
+ );
return (
<>
- {isNavigating && }
+ {(isNavigating || isPending) && (
+
+ )}
-
- {posts.slice(0, 6).map((blog: BlogPost) => (
-
-
-
- ))}
-
-
- Read More Articles
-
-
+ {posts.length > 0 ? (
+
+ {posts.slice(0, 6).map(blog => (
+
+
+
+ ))}
+ ) : (
+
+
+ No blog posts available at the moment.
+
+
+ )}
+
+
+
+ Read More Articles
+
+
>
diff --git a/components/landing-page/blog/StreamingBlogGrid.tsx b/components/landing-page/blog/StreamingBlogGrid.tsx
index 3f5d7129..1ba8f744 100644
--- a/components/landing-page/blog/StreamingBlogGrid.tsx
+++ b/components/landing-page/blog/StreamingBlogGrid.tsx
@@ -1,12 +1,8 @@
'use client';
-import React, { useState, useCallback, useMemo, useEffect } from 'react';
-import { BlogPost } from '@/types/blog';
-import {
- getBlogPosts,
- searchBlogPosts,
- getBlogCategories,
-} from '@/lib/api/blog';
+import React, { useState, useCallback, useMemo, useTransition } from 'react';
+import { BlogPost, GetBlogPostsResponse } from '@/types/blog';
+import { useRouter } from 'next/navigation';
import BlogCard from './BlogCard';
import { Search, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
@@ -24,49 +20,42 @@ interface StreamingBlogGridProps {
initialPosts: BlogPost[];
hasMore: boolean;
initialPage: number;
+ totalPages: number;
+ onLoadMore: (page: number) => Promise ;
}
const StreamingBlogGrid: React.FC = ({
initialPosts,
hasMore: initialHasMore,
initialPage,
+ onLoadMore,
}) => {
const [allPosts, setAllPosts] = useState(initialPosts);
const [currentPage, setCurrentPage] = useState(initialPage);
const [hasMore, setHasMore] = useState(initialHasMore);
- const [visiblePosts, setVisiblePosts] = useState(12);
const [selectedCategories, setSelectedCategories] = useState([]);
const [sortOrder, setSortOrder] = useState<'Latest' | 'Oldest' | ''>('');
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isNavigating, setIsNavigating] = useState(false);
- const [availableCategories, setAvailableCategories] = useState([]);
- const [isLoadingCategories, setIsLoadingCategories] = useState(false);
const [error, setError] = useState(null);
+ const [isPending, startTransition] = useTransition();
+ const router = useRouter();
- useEffect(() => {
- const fetchCategories = async () => {
- setIsLoadingCategories(true);
- try {
- const categories = await getBlogCategories();
- const categoryNames = categories.map(cat => cat.name);
- setAvailableCategories(categoryNames);
- } catch {
- setAvailableCategories(['Web3', 'Community', 'Category']);
- } finally {
- setIsLoadingCategories(false);
- }
- };
-
- fetchCategories();
- }, []);
+ // Extract unique categories from all posts
+ const availableCategories = useMemo(() => {
+ const categories = new Set(
+ allPosts.map(post => post.categories || []).flat()
+ );
+ return Array.from(categories).sort();
+ }, [allPosts]);
const filteredPosts = useMemo(() => {
let filtered = allPosts;
if (selectedCategories.length > 0) {
filtered = filtered.filter(post =>
- selectedCategories.includes(post.category)
+ post.categories?.some(category => selectedCategories.includes(category))
);
}
@@ -76,14 +65,15 @@ const StreamingBlogGrid: React.FC = ({
post =>
post.title.toLowerCase().includes(query) ||
post.excerpt.toLowerCase().includes(query) ||
- post.tags.some(tag => tag.toLowerCase().includes(query))
+ (post.tags &&
+ post.tags.some(tag => tag.tag.name.toLowerCase().includes(query)))
);
}
if (sortOrder) {
filtered = [...filtered].sort((a, b) => {
- const dateA = new Date(a.publishedAt).getTime();
- const dateB = new Date(b.publishedAt).getTime();
+ const dateA = new Date(a.coverImage).getTime();
+ const dateB = new Date(b.createdAt).getTime();
return sortOrder === 'Latest' ? dateB - dateA : dateA - dateB;
});
}
@@ -91,8 +81,9 @@ const StreamingBlogGrid: React.FC = ({
return filtered;
}, [allPosts, selectedCategories, searchQuery, sortOrder]);
- const displayPosts = filteredPosts.slice(0, visiblePosts);
- const hasMorePostsToShow = hasMore;
+ const displayPosts = filteredPosts;
+ const hasMorePostsToShow =
+ hasMore && !searchQuery && selectedCategories.length === 0;
const sortOptions: Array<'Latest' | 'Oldest'> = ['Latest', 'Oldest'];
@@ -104,54 +95,17 @@ const StreamingBlogGrid: React.FC = ({
try {
const nextPage = currentPage + 1;
+ const response = await onLoadMore(nextPage);
- let data;
- if (searchQuery.trim()) {
- data = await searchBlogPosts({
- q: searchQuery,
- page: nextPage,
- limit: 12,
- category:
- selectedCategories.length === 1 ? selectedCategories[0] : undefined,
- tags: selectedCategories.length > 1 ? selectedCategories : undefined,
- });
- } else {
- data = await getBlogPosts({
- page: nextPage,
- limit: 12,
- category:
- selectedCategories.length === 1 ? selectedCategories[0] : undefined,
- tags: selectedCategories.length > 1 ? selectedCategories : undefined,
- sort:
- sortOrder === 'Latest'
- ? 'latest'
- : sortOrder === 'Oldest'
- ? 'oldest'
- : 'latest',
- });
- }
-
- if (data.posts && data.posts.length > 0) {
- setAllPosts(prev => [...prev, ...data.posts]);
- setCurrentPage(nextPage);
- setHasMore(data.hasMore);
- setVisiblePosts(prev => prev + data.posts.length);
- } else {
- setHasMore(false);
- }
+ setAllPosts(prev => [...prev, ...response.data]);
+ setCurrentPage(nextPage);
+ setHasMore(response.hasMore);
} catch {
setError('Failed to load more posts. Please try again.');
} finally {
setIsLoading(false);
}
- }, [
- isLoading,
- hasMore,
- currentPage,
- searchQuery,
- selectedCategories,
- sortOrder,
- ]);
+ }, [isLoading, hasMore, currentPage, onLoadMore]);
const handleCategoryChange = (category: string) => {
setSelectedCategories(prev =>
@@ -159,28 +113,27 @@ const StreamingBlogGrid: React.FC = ({
? prev.filter(c => c !== category)
: [...prev, category]
);
- setVisiblePosts(12);
- setCurrentPage(1);
- setHasMore(true);
};
const handleSearchChange = (e: React.ChangeEvent) => {
setSearchQuery(e.target.value);
- setVisiblePosts(12);
- setCurrentPage(1);
- setHasMore(true);
};
- const handleCardClick = useCallback(() => {
- setIsNavigating(true);
- setTimeout(() => {
- setIsNavigating(false);
- }, 2000);
- }, []);
+ const handleCardClick = useCallback(
+ (slug: string) => {
+ setIsNavigating(true);
+ startTransition(() => {
+ router.push(`/blog/${slug}`);
+ });
+ },
+ [router]
+ );
return (
<>
- {isNavigating && }
+ {(isNavigating || isPending) && (
+
+ )}{' '}
@@ -219,7 +172,7 @@ const StreamingBlogGrid: React.FC = ({
= ({
= ({
selectedCategories.length > 0 &&
'border-[#A7F950]/30 bg-[#0F1A0B] text-[#A7F950]'
)}
- disabled={isLoadingCategories}
>
diff --git a/components/landing-page/navbar.tsx b/components/landing-page/navbar.tsx
index c4d47563..594a7938 100644
--- a/components/landing-page/navbar.tsx
+++ b/components/landing-page/navbar.tsx
@@ -51,7 +51,7 @@ const MENU_ITEMS = [
// Types
interface UserProfile {
firstName?: string | null;
- avatar?: string | null;
+ image?: string | null;
}
interface User {
@@ -350,7 +350,6 @@ const MobileMenu = ({
const displayName = useMemo(() => {
return user?.name || user?.profile?.firstName || 'User';
}, [user]);
-
return (
@@ -388,12 +387,11 @@ const MobileMenu = ({
showCloseButton={true}
>
- {/* User Info (if authenticated) */}
{isAuthenticated && user && (
diff --git a/components/landing-page/project/CreateProjectModal/index.tsx b/components/landing-page/project/CreateProjectModal/index.tsx
index d895c4a2..45de8a0e 100644
--- a/components/landing-page/project/CreateProjectModal/index.tsx
+++ b/components/landing-page/project/CreateProjectModal/index.tsx
@@ -135,6 +135,24 @@ const CreateProjectModal = ({ open, setOpen }: CreateProjectModalProps) => {
return () => {};
}, [currentStep]);
+ // Auto-trigger transaction signing when we reach the signing state
+ useEffect(() => {
+ if (
+ flowStep === 'signing' &&
+ unsignedTransaction &&
+ !isSigningTransaction &&
+ submitErrors.length === 0
+ ) {
+ // Automatically trigger signing without requiring manual button click
+ handleSignTransaction();
+ }
+ }, [
+ flowStep,
+ unsignedTransaction,
+ isSigningTransaction,
+ submitErrors.length,
+ ]);
+
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
@@ -248,10 +266,9 @@ const CreateProjectModal = ({ open, setOpen }: CreateProjectModalProps) => {
backup: contact.backupContact || '',
},
socialLinks: apiSocialLinks,
- contractId: '',
- escrowAddress: '',
+ escrowId: '',
transactionHash: '',
- escrowDetails: {},
+ validateMilestones: true,
};
};
@@ -353,24 +370,19 @@ const CreateProjectModal = ({ open, setOpen }: CreateProjectModalProps) => {
// Add escrow data
const projectRequest: CreateCrowdfundingProjectRequest = {
...apiRequest,
- contractId,
- escrowAddress: contractId, // In Stellar, contractId is the escrow address
+ escrowId: contractId, // Use contractId as escrowId
transactionHash,
- escrowDetails: {}, // Optional escrow details
+ validateMilestones: true,
};
// Create the project
- const response = await createCrowdfundingProject(projectRequest);
+ await createCrowdfundingProject(projectRequest);
- if (response.success) {
- // Project created successfully
- setFlowStep('success');
- setShowSuccess(true);
- setIsSigningTransaction(false);
- setIsSubmitting(false);
- } else {
- throw new Error(response.message || 'Failed to create project');
- }
+ // Project created successfully (new response structure)
+ setFlowStep('success');
+ setShowSuccess(true);
+ setIsSigningTransaction(false);
+ setIsSubmitting(false);
} catch (error) {
let errorMessage = 'Failed to create project. Please try again.';
@@ -624,7 +636,8 @@ const CreateProjectModal = ({ open, setOpen }: CreateProjectModalProps) => {
description: payload.basic?.vision || payload.details?.vision || '',
platformFee: 4, // 4% platform fee
trustline: {
- address: 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA', // USDC trustline
+ address: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5',
+ symbol: 'USDC',
},
roles: {
approver: walletAddress,
@@ -856,30 +869,27 @@ const CreateProjectModal = ({ open, setOpen }: CreateProjectModalProps) => {
if (flowStep === 'success' || showSuccess) {
return ;
}
+ // Show loading screen during signing and confirming states
if (
- flowStep === 'signing' &&
- unsignedTransaction &&
- !isSigningTransaction
+ flowStep === 'signing' ||
+ flowStep === 'confirming' ||
+ isSigningTransaction
) {
return (
0}
errorMessage={submitErrors[0]}
/>
);
}
- if (flowStep === 'confirming' || isSigningTransaction) {
- return (
-
- );
- }
switch (currentStep) {
case 1:
diff --git a/components/landing-page/project/ProjectCard.tsx b/components/landing-page/project/ProjectCard.tsx
index 810450a9..6d6d7714 100644
--- a/components/landing-page/project/ProjectCard.tsx
+++ b/components/landing-page/project/ProjectCard.tsx
@@ -2,19 +2,21 @@
import { Progress } from '@/components/ui/progress';
import { formatNumber } from '@/lib/utils';
import { useRouter } from 'nextjs-toploader/app';
+import type { Crowdfunding } from '@/types/project';
type ProjectCardProps = {
newTab?: boolean;
- projectId?: string;
- creatorName: string;
- creatorLogo: string;
- projectImage: string;
- projectTitle: string;
- projectDescription: string;
- status: 'Validation' | 'Funding' | 'Funded' | 'Completed';
- deadlineInDays: number;
- milestoneRejected?: boolean;
isFullWidth?: boolean;
+ project?: Crowdfunding;
+ // Legacy props for backward compatibility
+ projectId?: string;
+ creatorName?: string;
+ creatorLogo?: string;
+ projectImage?: string;
+ projectTitle?: string;
+ projectDescription?: string;
+ status?: 'Validation' | 'Funding' | 'Funded' | 'Completed';
+ deadlineInDays?: number;
votes?: {
current: number;
goal: number;
@@ -30,25 +32,75 @@ type ProjectCardProps = {
};
};
function ProjectCard({
- projectId,
+ project,
newTab = false,
+ isFullWidth = false,
+ projectId,
creatorName,
creatorLogo,
projectImage,
projectTitle,
projectDescription,
- status,
- deadlineInDays,
- milestoneRejected,
- isFullWidth = false,
+ status: legacyStatus,
+ deadlineInDays: legacyDeadline,
votes,
funding,
milestones,
}: ProjectCardProps) {
const router = useRouter();
+
+ const isNewFormat =
+ !!project && typeof project === 'object' && 'fundingGoal' in project;
+ const currentProjectId =
+ (isNewFormat && project ? project.id : projectId) || '';
+ const currentCreatorName =
+ (isNewFormat && project ? project.project.creator.name : creatorName) ||
+ 'Unknown Creator';
+ const currentCreatorLogo =
+ (isNewFormat && project ? project.project.creator.image : creatorLogo) ||
+ '/user.png';
+ const currentProjectImage =
+ (isNewFormat && project ? project.project.logo : projectImage) ||
+ '/landing/explore/project-placeholder-1.png';
+ const currentProjectTitle =
+ (isNewFormat && project ? project.project.title : projectTitle) || '';
+ const currentProjectDescription =
+ (isNewFormat && project
+ ? project.project.description || project.project.vision
+ : projectDescription) || '';
+
+ const getProjectStatus = () => {
+ if (!isNewFormat || !project) return legacyStatus || 'Funding';
+
+ const status = project.project.status;
+ if (status === 'IDEA') return 'Validation';
+ if (status === 'ACTIVE') return 'Funding';
+ if (status === 'COMPLETED') return 'Completed';
+ return 'Funding'; // default
+ };
+
+ const getDeadlineInDays = () => {
+ if (!isNewFormat || !project) return legacyDeadline || 0;
+
+ try {
+ const now = new Date();
+ const end = new Date(project.fundingEndDate);
+ return Math.max(
+ 0,
+ Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
+ );
+ } catch {
+ return 0;
+ }
+ };
+
+ const status = getProjectStatus();
+ const deadlineInDays = getDeadlineInDays();
+
const handleClick = () => {
- router.push(`/projects/${projectId}`);
+ router.push(`/projects/${currentProjectId}`);
};
+
const getStatusStyles = () => {
switch (status) {
case 'Funding':
@@ -65,11 +117,17 @@ function ProjectCard({
};
const getDeadlineInfo = () => {
- if (status === 'Completed' && milestoneRejected) {
- return {
- text: '1 Milestone Rejected',
- className: 'text-red-500',
- };
+ if (status === 'Completed' && isNewFormat && project) {
+ // Check if any milestones are rejected
+ const rejectedMilestones = project.milestones.filter(
+ m => m.status === 'rejected'
+ );
+ if (rejectedMilestones.length > 0) {
+ return {
+ text: `${rejectedMilestones.length} Milestone${rejectedMilestones.length > 1 ? 's' : ''} Rejected`,
+ className: 'text-red-500',
+ };
+ }
}
if (deadlineInDays <= 3) {
@@ -102,10 +160,12 @@ function ProjectCard({
- {creatorName}
+
+ {currentCreatorName}
+
@@ -120,16 +180,16 @@ function ProjectCard({
- {projectTitle}
+ {currentProjectTitle}
- {projectDescription}
+ {currentProjectDescription}
@@ -137,21 +197,42 @@ function ProjectCard({
- {status === 'Validation' && votes && (
+ {status === 'Validation' && (
- {formatNumber(votes.current)}/{formatNumber(votes.goal)}{' '}
+ {formatNumber(
+ isNewFormat ? project.project.votes || 0 : votes?.current || 0
+ )}
+ /{formatNumber(isNewFormat ? 100 : votes?.goal || 100)}{' '}
Votes
)}
- {status === 'Funding' && funding && (
+ {status === 'Funding' && (
- {formatNumber(funding.current)}/{formatNumber(funding.goal)}{' '}
- {funding.currency} raised
+ {formatNumber(
+ isNewFormat ? project.fundingRaised : funding?.current || 0
+ )}
+ /
+ {formatNumber(
+ isNewFormat ? project.fundingGoal : funding?.goal || 0
+ )}{' '}
+
+ {isNewFormat
+ ? project.fundingCurrency
+ : funding?.currency || 'USD'}{' '}
+ raised
+
)}
- {(status === 'Funded' || status === 'Completed') && milestones && (
+ {(status === 'Funded' || status === 'Completed') && (
- {milestones.current}/{milestones.goal}{' '}
+ {isNewFormat
+ ? project.milestones.filter(m => m.status === 'completed')
+ .length
+ : milestones?.current || 0}
+ /
+ {isNewFormat
+ ? project.milestones.length
+ : milestones?.goal || 0}{' '}
Milestones Submitted
)}
@@ -167,16 +248,27 @@ function ProjectCard({
|