diff --git a/COMMENT_SYSTEM_README.md b/COMMENT_SYSTEM_README.md new file mode 100644 index 00000000..e6b5de84 --- /dev/null +++ b/COMMENT_SYSTEM_README.md @@ -0,0 +1,571 @@ +# ๐Ÿ—ฃ๏ธ Comment System Implementation + +A comprehensive, production-ready comment system built with React/Next.js, featuring real-time updates, advanced moderation, and rich user interactions. + +## ๐Ÿ“‹ Table of Contents + +- [Features](#features) +- [Architecture](#architecture) +- [API Integration](#api-integration) +- [Components](#components) +- [Hooks](#hooks) +- [Usage Examples](#usage-examples) +- [Testing](#testing) +- [Migration Guide](#migration-guide) + +## โœจ Features + +### Core Features + +- โœ… **Multi-entity Support**: Comments on Projects, Bounties, Hackathons, Grants, etc. +- โœ… **Threaded Comments**: Nested replies with configurable depth +- โœ… **8 Reaction Types**: LIKE, DISLIKE, LOVE, LAUGH, THUMBS_UP, THUMBS_DOWN, FIRE, ROCKET +- โœ… **Real-time Updates**: WebSocket integration for live comment updates +- โœ… **Content Validation**: Client-side validation with spam detection +- โœ… **Advanced Moderation**: Report system with resolution workflow + +### User Experience + +- โœ… **Optimistic Updates**: Instant UI feedback +- โœ… **Loading States**: Proper loading indicators +- โœ… **Error Handling**: Comprehensive error states +- โœ… **Responsive Design**: Mobile-first responsive UI +- โœ… **Accessibility**: WCAG compliant components + +### Developer Experience + +- โœ… **TypeScript**: Full type safety +- โœ… **Modular Hooks**: Reusable, composable hooks +- โœ… **Generic Components**: Entity-agnostic components +- โœ… **Comprehensive Testing**: Test page with all features + +## ๐Ÿ—๏ธ Architecture + +### Data Flow + +``` +User Action โ†’ Hook โ†’ API โ†’ Backend โ†’ WebSocket โ†’ UI Update +``` + +### Component Hierarchy + +``` +GenericCommentThread +โ”œโ”€โ”€ CommentInput (with validation) +โ”œโ”€โ”€ CommentItem[] +โ”‚ โ”œโ”€โ”€ CommentReactions +โ”‚ โ”œโ”€โ”€ CommentReplyInput +โ”‚ โ””โ”€โ”€ CommentReportForm +โ””โ”€โ”€ LoadMoreButton +``` + +### Hook Architecture + +``` +useCommentSystem (main orchestrator) +โ”œโ”€โ”€ useComments (data fetching) +โ”œโ”€โ”€ useCreateComment (create operations) +โ”œโ”€โ”€ useUpdateComment (update operations) +โ”œโ”€โ”€ useDeleteComment (delete operations) +โ”œโ”€โ”€ useReportComment (reporting) +โ””โ”€โ”€ useCommentReactions (reactions) +``` + +## ๐Ÿ”Œ API Integration + +### Base URL + +``` +/api/comments +``` + +### Endpoints Used + +- `GET /api/comments` - Fetch comments with filtering +- `POST /api/comments` - Create new comment +- `PUT /api/comments/:id` - Update comment +- `DELETE /api/comments/:id` - Delete comment +- `POST /api/comments/:id/reactions` - Add reaction +- `DELETE /api/comments/:id/reactions/:type` - Remove reaction +- `POST /api/comments/:id/report` - Report comment +- `GET /api/comments/:id/reactions` - Get reactions + +### Query Parameters + +```typescript +interface GetCommentsQuery { + entityType: CommentEntityType; + entityId: string; + authorId?: string; + parentId?: string; + status?: CommentStatus; + includeReactions?: boolean; + includeReports?: boolean; + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +} +``` + +## ๐Ÿงฉ Components + +### GenericCommentThread + +Main comment thread component that works with any entity type. + +```tsx + +``` + +### CommentModerationDashboard + +Moderation interface for handling reports and comment status. + +```tsx + +``` + +### Entity-Specific Components + +- `ProjectComments` - For project pages +- `HackathonComments` - For hackathon discussions +- `BountyComments` - For bounty discussions + +## ๐ŸŽฃ Hooks + +### useCommentSystem + +Main orchestrator hook that combines all comment functionality. + +```tsx +const commentSystem = useCommentSystem({ + entityType: CommentEntityType.PROJECT, + entityId: 'project-123', + page: 1, + limit: 20, + enabled: true, +}); +``` + +### Individual Hooks + +- `useComments` - Data fetching and pagination +- `useCreateComment` - Comment creation +- `useUpdateComment` - Comment editing +- `useDeleteComment` - Comment deletion +- `useReportComment` - Comment reporting +- `useCommentReactions` - Reaction management +- `useCommentRealtime` - WebSocket integration + +### useCommentRealtime + +Real-time updates via WebSocket. + +```tsx +useCommentRealtime( + { entityType, entityId, userId }, + { + onCommentCreated: comment => { + /* handle new comment */ + }, + onReactionAdded: data => { + /* handle reaction */ + }, + // ... other event handlers + } +); +``` + +## ๐Ÿ’ก Usage Examples + +### Basic Project Comments + +```tsx +import { ProjectComments } from '@/components/project-details/comment-section/project-comments'; + +function ProjectPage({ projectId }: { projectId: string }) { + return ( +
+ {/* Project content */} + +
+ ); +} +``` + +### Hackathon Comments + +```tsx +import { HackathonComments } from '@/components/hackathons/HackathonComments'; + +function HackathonPage({ hackathonId }: { hackathonId: string }) { + return ( +
+ {/* Hackathon content */} + +
+ ); +} +``` + +### Custom Comment Thread + +```tsx +import { GenericCommentThread } from '@/components/comments/GenericCommentThread'; +import { useCommentSystem } from '@/hooks/use-comment-system'; +import { CommentEntityType } from '@/types/comment'; + +function CustomComments({ + entityType, + entityId, +}: { + entityType: CommentEntityType; + entityId: string; +}) { + const commentSystem = useCommentSystem({ + entityType, + entityId, + enabled: true, + }); + + const currentUser = { + id: 'user-1', + name: 'John Doe', + isModerator: false, + }; + + return ( + + ); +} +``` + +## ๐ŸŒ WebSocket Real-time Integration + +### **Architecture Overview** + +The comment system uses WebSocket connections to provide real-time updates across all entity types. + +### **Connection Details** + +- **Namespace**: `/realtime` +- **Room Structure**: `{entityType}:{entityId}` +- **Authentication**: Via `userId` query parameter + +### **Supported Real-time Events** + +#### **1. Comment Operations** + +```typescript +// Event Structure +{ + entityType: 'PROJECT', + entityId: 'project-123', + update: { + type: 'comment-added' | 'comment-updated' | 'comment-deleted', + data: Comment // or { commentId: string } + } +} +``` + +#### **2. Reaction Operations** + +```typescript +// Event Structure +{ + entityType: 'PROJECT', + entityId: 'project-123', + update: { + type: 'reaction-added' | 'reaction-removed', + data: { + commentId: string, + reactionType: ReactionType, + userId: string + } + } +} +``` + +### **Frontend Integration** + +#### **Automatic Subscription** + +```typescript +// The useCommentRealtime hook handles this automatically +const realtime = useCommentRealtime( + { entityType, entityId, userId, enabled: true }, + { + onCommentCreated: comment => addCommentToUI(comment), + onCommentUpdated: comment => updateCommentInUI(comment), + onCommentDeleted: commentId => removeCommentFromUI(commentId), + onReactionAdded: data => + updateReactionCount(data.commentId, data.reactionType), + onReactionRemoved: data => + updateReactionCount(data.commentId, data.reactionType), + } +); +``` + +#### **Manual Connection (if needed)** + +```typescript +import io from 'socket.io-client'; + +const socket = io(`${BACKEND_URL}/realtime`, { + transports: ['websocket', 'polling'], + withCredentials: true, + query: { userId: currentUserId }, +}); + +// Subscribe to entity +socket.emit('subscribe-entity', { + entityType: 'PROJECT', + entityId: 'project-123', +}); + +// Listen for updates +socket.on('entity-update', update => { + // Handle real-time events +}); +``` + +### **Backend Broadcasting** + +The backend automatically broadcasts events when: + +- Comments are created, updated, or deleted +- Reactions are added or removed +- Comment status changes (moderation) + +### **Performance Benefits** + +- โœ… **Efficient**: Room-based subscriptions (only relevant updates) +- โœ… **Scalable**: Supports unlimited entity types +- โœ… **Real-time**: Instant updates without polling +- โœ… **Reliable**: Automatic reconnection on disconnect + +## ๐Ÿงช Testing + +### Test Page + +Visit `/test-new-comments` to test all features: + +- Comment creation and editing +- Nested replies +- All 8 reaction types +- Reporting system +- Moderation dashboard +- Real-time updates +- Content validation + +### Test Configuration + +- **Entity Type**: Switch between different entity types +- **Entity ID**: Test with different entity IDs +- **User Roles**: Test moderator vs regular user features + +## ๐Ÿ”„ Migration Guide + +### From Old Project Comments + +**Before:** + +```tsx +import { ProjectComments } from '@/components/project-details/comment-section/project-comments'; + +// Old component with limited features +; +``` + +**After:** + +```tsx +import { ProjectComments } from '@/components/project-details/comment-section/project-comments'; + +// New component with full feature set +; +``` + +The `ProjectComments` component has been updated internally to use the new system while maintaining the same API. + +### Adding Comments to New Entities + +1. **Import the GenericCommentThread:** + +```tsx +import { GenericCommentThread } from '@/components/comments/GenericCommentThread'; +import { useCommentSystem } from '@/hooks/use-comment-system'; +``` + +2. **Initialize the comment system:** + +```tsx +const commentSystem = useCommentSystem({ + entityType: CommentEntityType.YOUR_ENTITY, + entityId: yourEntityId, + enabled: true, +}); +``` + +3. **Render the comment thread:** + +```tsx + +``` + +## ๐Ÿ“Š Performance Considerations + +### Optimization Strategies + +- **Pagination**: Cursor-based pagination for large threads +- **Virtual Scrolling**: For threads with 100+ comments +- **Debounced Updates**: Batch rapid reaction updates +- **Optimistic Updates**: Instant UI feedback with rollback on error +- **Lazy Loading**: Load nested replies on demand + +### Memory Management + +- **Cleanup**: Proper WebSocket connection cleanup +- **Debouncing**: Prevent excessive API calls +- **Caching**: Local comment data caching with TTL + +## ๐Ÿ”’ Security Features + +### Content Validation + +- **Spam Detection**: Pattern-based spam filtering +- **Link Limits**: Maximum links per comment +- **Length Limits**: Min/max character limits +- **Prohibited Content**: Configurable banned patterns + +### Rate Limiting + +- **Comment Creation**: Rate limiting per user +- **API Calls**: Backend rate limiting +- **WebSocket**: Connection limits + +### Moderation + +- **Content Filtering**: Automatic content analysis +- **Report System**: User reporting with review queue +- **Status Management**: Hide/delete/approve comments +- **Audit Trail**: Full moderation history + +## ๐Ÿš€ Deployment Checklist + +- [ ] Backend API endpoints deployed +- [ ] WebSocket server configured +- [ ] Database migrations completed +- [ ] Content validation rules configured +- [ ] Moderation queue initialized +- [ ] Rate limiting configured +- [ ] CDN configured for static assets +- [ ] Monitoring and logging set up + +## ๐Ÿ“ˆ Monitoring & Analytics + +### Key Metrics + +- **Comment Volume**: Comments per entity type +- **Engagement Rate**: Reactions per comment +- **Moderation Load**: Reports processed per day +- **Real-time Performance**: WebSocket latency +- **Error Rates**: API failure rates + +### Logging + +- **User Actions**: Comment creation, reactions, reports +- **Moderation Actions**: Report resolutions, status changes +- **System Events**: WebSocket connections, API calls +- **Errors**: Failed operations with context + +## ๐Ÿ› Troubleshooting + +### Common Issues + +**Comments not loading:** + +- Check API endpoints are accessible +- Verify entity type and ID are correct +- Check network connectivity + +**Real-time updates not working:** + +- Verify WebSocket server is running +- Check firewall settings +- Confirm user permissions + +**Reactions not updating:** + +- Check reaction permissions +- Verify user authentication +- Confirm WebSocket connection + +**Moderation not working:** + +- Verify user role permissions +- Check moderation API endpoints +- Confirm database connections + +### Debug Mode + +Enable debug logging by setting: + +```env +NEXT_PUBLIC_COMMENT_DEBUG=true +``` + +## ๐Ÿค Contributing + +### Code Standards + +- Use TypeScript for all new code +- Follow existing component patterns +- Add comprehensive error handling +- Include loading states +- Test all features thoroughly + +### Adding New Features + +1. Update type definitions first +2. Implement API integration +3. Create/update hooks +4. Build UI components +5. Add tests and documentation +6. Update this README + +--- + +## ๐Ÿ“ž Support + +For questions or issues with the comment system: + +1. Check this README first +2. Review the test page at `/test-new-comments` +3. Check browser console for errors +4. Verify backend API is responding +5. Review WebSocket connection status + +The comment system is designed to be robust, scalable, and maintainable. All features have been thoroughly tested and are production-ready. diff --git a/app/(landing)/about/AboutUsHero.tsx b/app/(landing)/about/AboutUsHero.tsx index 207f3022..53c6af0f 100644 --- a/app/(landing)/about/AboutUsHero.tsx +++ b/app/(landing)/about/AboutUsHero.tsx @@ -66,7 +66,7 @@ export default function AboutUsHero() { return (
-
+
+
Explore Projects @@ -71,7 +71,7 @@ export default function AboutUsHero() { size='lg' fullWidth aria-label='Submit your project idea for funding' - className='min-h-[48px] touch-manipulation sm:min-h-[44px]' + className='min-h-12 touch-manipulation sm:min-h-11' > Submit Your Idea @@ -79,7 +79,7 @@ export default function AboutUsHero() {
-
+
{
{/* Team Member 1 */}
-
+

Collins Ikechukwu @@ -45,7 +45,7 @@ const OurTeam = () => { Blockchain Developer


{ {/* Team Member 2 */}
-
+

Nnaji Benjamin @@ -104,7 +104,7 @@ const OurTeam = () => { Full-Stack & Blockchain Developer


{ return (
-
+
diff --git a/app/(landing)/accept-invitation/[invitationId]/page.tsx b/app/(landing)/accept-invitation/[invitationId]/page.tsx index b0e7ecf6..6a7e9987 100644 --- a/app/(landing)/accept-invitation/[invitationId]/page.tsx +++ b/app/(landing)/accept-invitation/[invitationId]/page.tsx @@ -4,7 +4,9 @@ import { useEffect, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { authClient } from '@/lib/auth-client'; import { toast } from 'sonner'; -import { Loader2, CheckCircle2, XCircle } from 'lucide-react'; +import { CheckCircle2, XCircle } from 'lucide-react'; +import LoadingSpinner from '@/components/LoadingSpinner'; +import { BoundlessButton } from '@/components/buttons'; export default function AcceptInvitationPage() { const params = useParams(); @@ -12,14 +14,12 @@ export default function AcceptInvitationPage() { const [status, setStatus] = useState<'loading' | 'success' | 'error'>( 'loading' ); - const [errorMessage, setErrorMessage] = useState(''); const invitationId = params.invitationId as string; useEffect(() => { const acceptInvitation = async () => { if (!invitationId) { setStatus('error'); - setErrorMessage('No invitation ID provided'); return; } @@ -54,9 +54,6 @@ export default function AcceptInvitationPage() { throw new Error('Failed to accept invitation'); } } catch { - setErrorMessage( - 'Failed to accept invitation. It may be expired or invalid.' - ); toast.error( 'Failed to accept invitation. It may be expired or invalid.' ); @@ -68,9 +65,38 @@ export default function AcceptInvitationPage() { }, [invitationId, router]); return ( -
-
- {status === 'loading' && ( +
+ {status === 'loading' && ( + <> + +

Accepting invitation...

+ + )} + {status === 'success' && ( + <> + +

+ Invitation accepted! Redirecting... +

+ + )} + {status === 'error' && ( + <> + +

+ Failed to accept invitation. It may be expired or invalid. +

+ router.push('/')} + size='xl' + className='bg-primary hover:bg-primary/90 text-black' + > + Go to Home + + + )} + {/*
*/} + {/* {status === 'loading' && (

@@ -81,6 +107,7 @@ export default function AcceptInvitationPage() {

)} + {status === 'success' && (
@@ -110,8 +137,8 @@ export default function AcceptInvitationPage() { Go to Organizations
- )} -
+ )} */} + {/*
*/}
); } diff --git a/app/(landing)/accept-invitation/layout.tsx b/app/(landing)/accept-invitation/layout.tsx new file mode 100644 index 00000000..c2a4ee2b --- /dev/null +++ b/app/(landing)/accept-invitation/layout.tsx @@ -0,0 +1,22 @@ +import AnimatedAuthLayout from '@/components/auth/AnimatedAuthLayout'; +import { Metadata } from 'next'; +import React from 'react'; + +export const metadata: Metadata = { + title: 'Authentication - Boundless', + description: 'Sign in or create an account to access Boundless platform', + robots: 'noindex, nofollow', +}; + +interface AuthLayoutProps { + children: React.ReactNode; +} + +export default function AuthLayoutWrapper({ children }: AuthLayoutProps) { + return ( +
+
+ {children} +
+ ); +} diff --git a/app/(landing)/blog/[slug]/not-found.tsx b/app/(landing)/blog/[slug]/not-found.tsx index ea4950b5..0dd90ca8 100644 --- a/app/(landing)/blog/[slug]/not-found.tsx +++ b/app/(landing)/blog/[slug]/not-found.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'; export default function BlogNotFound() { return ( -
+

404

diff --git a/app/(landing)/blog/[slug]/page.tsx b/app/(landing)/blog/[slug]/page.tsx index fbc9df1a..6e2557c6 100644 --- a/app/(landing)/blog/[slug]/page.tsx +++ b/app/(landing)/blog/[slug]/page.tsx @@ -1,17 +1,35 @@ import React from 'react'; import { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import BlogPostDetails from '../../../../components/landing-page/blog/BlogPostDetails'; +import BlogPostDetails from '@/components/landing-page/blog/BlogPostDetails'; import { getBlogPost, getBlogPosts } from '@/lib/api/blog'; import { generateBlogPostMetadata } from '@/lib/metadata'; -export async function generateStaticParams() { +interface BlogPostPageProps { + params: Promise<{ slug: string }>; +} + +interface StaticParams { + slug: string; +} + +const STATIC_GENERATION_LIMIT = 100; + +export async function generateStaticParams(): Promise { try { - // For static generation, we'll need to fetch all posts - // This might need to be adjusted based on your backend implementation - const { posts } = await getBlogPosts({ page: 1, limit: 50 }); + const response = await getBlogPosts({ + page: 1, + limit: STATIC_GENERATION_LIMIT, + status: 'PUBLISHED', + }); + + const data = response.data; + + if (!data || data.length === 0) { + return []; + } - return posts.map(post => ({ + return data.map(post => ({ slug: post.slug, })); } catch { @@ -21,48 +39,80 @@ export async function generateStaticParams() { export async function generateMetadata({ params, -}: { - params: Promise<{ slug: string }>; -}): Promise { - const { slug } = await params; - +}: BlogPostPageProps): Promise { try { + const { slug } = await params; + + if (!slug || typeof slug !== 'string') { + return getDefaultMetadata(); + } + const post = await getBlogPost(slug); if (!post) { - return { - title: 'Blog Post Not Found', - description: 'The requested blog post could not be found.', - }; + return getNotFoundMetadata(); } return generateBlogPostMetadata(post); } catch { - return { - title: 'Blog Post Not Found', - description: 'The requested blog post could not be found.', - }; + return getDefaultMetadata(); } } -const BlogPostPage = async ({ - params, -}: { - params: Promise<{ slug: string }>; -}) => { - const { slug } = await params; - +const BlogPostPage = async ({ params }: BlogPostPageProps) => { try { + const { slug } = await params; + + if (!slug || typeof slug !== 'string') { + notFound(); + } + const post = await getBlogPost(slug); if (!post) { notFound(); } + if (!post.id || !post.title || !post.content) { + notFound(); + } + return ; - } catch { + } catch (error) { + if (error instanceof Error) { + if ( + error.message.includes('404') || + error.message.includes('not found') + ) { + notFound(); + } + } + notFound(); } }; +function getDefaultMetadata(): Metadata { + return { + title: 'Blog Post | Boundless', + description: 'Read our latest blog posts and insights.', + robots: { + index: false, + follow: true, + }, + }; +} + +function getNotFoundMetadata(): Metadata { + return { + title: 'Blog Post Not Found | Boundless', + description: + 'The requested blog post could not be found. Please check the URL or browse our other posts.', + robots: { + index: false, + follow: true, + }, + }; +} + export default BlogPostPage; diff --git a/app/(landing)/blog/page.tsx b/app/(landing)/blog/page.tsx index cddd0c83..317bb67e 100644 --- a/app/(landing)/blog/page.tsx +++ b/app/(landing)/blog/page.tsx @@ -1,75 +1,80 @@ -import React, { Suspense } from 'react'; -import { Metadata } from 'next'; -import { generatePageMetadata } from '@/lib/metadata'; +'use client'; + +import { useState, useEffect } from 'react'; +import BlogHero from '@/components/landing-page/blog/BlogHero'; import StreamingBlogGrid from '@/components/landing-page/blog/StreamingBlogGrid'; import { getBlogPosts } from '@/lib/api/blog'; -import TestimonialSection from '@/components/testimonials/TestimonialsSection'; -import { testimonials } from '@/components/testimonials/data/testimonial'; -import { Skeleton } from '@/components/ui/skeleton'; -import BlogCardSkeleton from '@/components/landing-page/blog/BlogCardSkeleton'; -import BlogHero from '@/components/landing-page/blog/BlogHero'; +import { GetBlogPostsResponse } from '@/types/blog'; +import { Loader2 } from 'lucide-react'; + +const BlogsPage = () => { + const [blogData, setBlogData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchInitialPosts = async () => { + try { + setIsLoading(true); + const response = await getBlogPosts({ + page: 1, + limit: 12, + sortBy: 'createdAt', + sortOrder: 'desc', + status: 'PUBLISHED', + }); + setBlogData(response); + } catch { + setError('Failed to load blog posts. Please try again.'); + } finally { + setIsLoading(false); + } + }; -export const metadata: Metadata = generatePageMetadata('blog'); + fetchInitialPosts(); + }, []); -async function StreamingBlogGridWrapper() { - try { - // Fetch blog posts from external backend API - const result = await getBlogPosts({ - page: 1, + const handleLoadMore = async ( + page: number + ): Promise => { + const response = await getBlogPosts({ + page, limit: 12, - sort: 'latest', + sortBy: 'createdAt', + sortOrder: 'desc', + status: 'PUBLISHED', }); + return response; + }; - return ( - - ); - } catch { - return ( - - ); - } -} - -function BlogGridLoading() { return (
-
-
-
- - -
- -
-
- -
-
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
-
- ); -} - -const BlogPage = async () => { - return ( -
- }> - - + + {isLoading ? ( +
+
+ + Loading articles... +
+
+ ) : error ? ( +
+

{error}

+
+ ) : blogData ? ( + + ) : null}
-
); }; -export default BlogPage; +export default BlogsPage; diff --git a/app/(landing)/code-of-conduct/CodeOfConductContent.tsx b/app/(landing)/code-of-conduct/CodeOfConductContent.tsx index cf4a5c32..03c7c97a 100644 --- a/app/(landing)/code-of-conduct/CodeOfConductContent.tsx +++ b/app/(landing)/code-of-conduct/CodeOfConductContent.tsx @@ -166,7 +166,7 @@ const CodeOfConductContent = () => { loading='lazy' />
-
+
Background { {/* Two Column Layout */}
{/* Left Column - Table of Contents */} -

- + ({ + name: phase.name || '', + startDate: phase.startDate || '', + endDate: phase.endDate || '', + })), + }} + />

diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx index fc58acc9..1a22dd08 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx @@ -18,15 +18,20 @@ export default function ParticipantsPage() { participantsLoading, participantsError, fetchParticipants, + currentHackathon, + fetchHackathon, } = useHackathons({ organizationId, autoFetch: false, }); + // Get the actual hackathon ID from the fetched hackathon data + const actualHackathonId = currentHackathon?.id; + // Handler to refresh participants after review actions const handleReviewSuccess = () => { - if (organizationId && hackathonId) { - fetchParticipants(hackathonId); + if (organizationId && actualHackathonId) { + fetchParticipants(actualHackathonId); } }; @@ -46,28 +51,43 @@ export default function ParticipantsPage() { useEffect(() => { if ( lastOrgIdRef.current !== organizationId || - lastHackathonIdRef.current !== hackathonId + lastHackathonIdRef.current !== (actualHackathonId || null) ) { // IDs changed, reset fetch flags hasFetchedParticipantsRef.current = false; hasFetchedStatisticsRef.current = false; lastOrgIdRef.current = organizationId; - lastHackathonIdRef.current = hackathonId; + lastHackathonIdRef.current = actualHackathonId || null; + } + }, [organizationId, actualHackathonId]); + + // First fetch the hackathon to get the actual ID + useEffect(() => { + if (organizationId && hackathonId && !currentHackathon) { + void fetchHackathon(hackathonId); } - }, [organizationId, hackathonId]); + }, [organizationId, hackathonId, currentHackathon, fetchHackathon]); - // Fetch participants on mount or when IDs change + // Fetch participants on mount or when actual hackathon ID is available useEffect(() => { - if (organizationId && hackathonId && !hasFetchedParticipantsRef.current) { + if ( + organizationId && + actualHackathonId && + !hasFetchedParticipantsRef.current + ) { hasFetchedParticipantsRef.current = true; - fetchParticipants(hackathonId); + fetchParticipants(actualHackathonId); } - }, [organizationId, hackathonId, fetchParticipants]); + }, [organizationId, actualHackathonId, fetchParticipants]); - // Fetch statistics only once on mount or when IDs change + // Fetch statistics only once on mount or when actual hackathon ID is available useEffect(() => { const loadStatistics = async () => { - if (!organizationId || !hackathonId || hasFetchedStatisticsRef.current) { + if ( + !organizationId || + !actualHackathonId || + hasFetchedStatisticsRef.current + ) { return; } @@ -76,7 +96,7 @@ export default function ParticipantsPage() { try { const response = await getHackathonStatistics( organizationId, - hackathonId + actualHackathonId ); setStatistics({ participantsCount: response.data.participantsCount, @@ -90,10 +110,10 @@ export default function ParticipantsPage() { } }; - if (organizationId && hackathonId) { + if (organizationId && actualHackathonId) { loadStatistics(); } - }, [organizationId, hackathonId]); + }, [organizationId, actualHackathonId]); // Ensure participants is always an array const participantsList = useMemo(() => { @@ -175,7 +195,7 @@ export default function ParticipantsPage() { ) : ( participantsList.map(participant => ( { const fields = [ - draft.information?.title, - draft.information?.banner, - draft.information?.description, - draft.information?.categories, - draft.timeline?.startDate, - draft.timeline?.submissionDeadline, - draft.timeline?.judgingDate, - draft.timeline?.winnerAnnouncementDate, - draft.timeline?.timezone, - draft.participation?.participantType, - draft.rewards?.prizeTiers?.length, - draft.judging?.criteria?.length, - draft.collaboration?.contactEmail, + draft.data.information?.name, + draft.data.information?.banner, + draft.data.information?.description, + draft.data.information?.categories, + draft.data.timeline?.startDate, + draft.data.timeline?.submissionDeadline, + draft.data.timeline?.judgingDate, + draft.data.timeline?.winnerAnnouncementDate, + draft.data.timeline?.timezone, + draft.data.participation?.participantType, + draft.data.rewards?.prizeTiers?.length, + draft.data.judging?.criteria?.length, + draft.data.collaboration?.contactEmail, ]; const filledFields = fields.filter(field => { @@ -92,7 +93,7 @@ export default function HackathonsPage() { organizationId, autoFetch: true, }); - + console.log('hackathons', hackathons); // Use the separate delete hook const { isDeleting, deleteHackathon } = useDeleteHackathon({ organizationId, @@ -116,7 +117,7 @@ export default function HackathonsPage() { type: 'draft' | 'hackathon'; data: HackathonDraft | Hackathon; }> = []; - + console.log({ drafts }); drafts.forEach(draft => { if (statusFilter === 'all' || statusFilter === 'draft') { items.push({ type: 'draft', data: draft }); @@ -124,10 +125,10 @@ export default function HackathonsPage() { }); hackathons.forEach(hackathon => { - if (hackathon.status === 'draft') return; + if (hackathon.status === 'DRAFT') return; if ( statusFilter === 'all' || - (statusFilter === 'open' && hackathon.status === 'published') + (statusFilter === 'open' && hackathon.status === 'PUBLISHED') ) { items.push({ type: 'hackathon', data: hackathon }); } @@ -137,7 +138,12 @@ export default function HackathonsPage() { if (searchQuery) { const query = searchQuery.toLowerCase(); filtered = items.filter(item => { - const title = item.data.information?.title?.toLowerCase() || ''; + const title = + item.type === 'draft' + ? ( + item.data as HackathonDraft + ).data.information?.name?.toLowerCase() || '' + : (item.data as Hackathon).name?.toLowerCase() || ''; return title.includes(query); }); } @@ -145,7 +151,11 @@ export default function HackathonsPage() { if (categoryFilter !== 'all') { filtered = filtered.filter(item => { const category = - item.data.information?.categories?.join(',')?.toLowerCase() || ''; + item.type === 'draft' + ? (item.data as HackathonDraft).data.information?.categories + ?.join(',') + ?.toLowerCase() || '' + : ''; // Categories filtering only applies to drafts for now return category.includes(categoryFilter.toLowerCase()); }); } @@ -162,18 +172,19 @@ export default function HackathonsPage() { const isLoading = hackathonsLoading || draftsLoading; const stats = useMemo(() => { - const published = hackathons.filter(h => h.status === 'published').length; + const published = hackathons.filter(h => h.status === 'PUBLISHED').length; const total = hackathons.length + drafts.length; return { published, drafts: drafts.length, total }; }, [hackathons, drafts]); const handleDeleteClick = (hackathonId: string) => { - const hackathon = allHackathons.find(item => item.data._id === hackathonId); + const hackathon = allHackathons.find(item => item.data.id === hackathonId); if (hackathon) { const title = - hackathon.data.information?.title || - hackathon.data.title || - 'Untitled Hackathon'; + hackathon.type === 'draft' + ? (hackathon.data as HackathonDraft).data.information?.name || + 'Untitled Hackathon' + : (hackathon.data as Hackathon).name || 'Untitled Hackathon'; setHackathonToDelete({ id: hackathonId, title }); setDeleteDialogOpen(true); } @@ -373,43 +384,54 @@ export default function HackathonsPage() { ) : (
{allHackathons.map(item => { - const isDraft = - item.type === 'draft' || - (item.data as Hackathon | HackathonDraft).status === 'draft'; + const isDraft = item.type === 'draft'; const hackathon = item.data; - const title = - hackathon.information?.title || - hackathon.title || - 'Untitled Hackathon'; + const title = isDraft + ? (hackathon as HackathonDraft).data.information?.name || + 'Untitled Hackathon' + : (hackathon as Hackathon).name || 'Untitled Hackathon'; const completion = isDraft ? calculateDraftCompletion(hackathon as HackathonDraft) : 0; - const endDate = - hackathon.timeline?.submissionDeadline || - hackathon.timeline?.winnerAnnouncementDate; - const totalPrize = - hackathon.rewards?.prizeTiers?.reduce( - (sum, tier) => sum + (tier.amount || 0), - 0 - ) || 0; + const endDate = isDraft + ? (hackathon as HackathonDraft).data.timeline + ?.submissionDeadline || + (hackathon as HackathonDraft).data.timeline + ?.winnerAnnouncementDate + : (hackathon as Hackathon).submissionDeadline || + (hackathon as Hackathon).endDate; + const totalPrize = isDraft + ? ( + hackathon as HackathonDraft + ).data.rewards?.prizeTiers?.reduce( + (sum: number, tier: any) => sum + (tier.amount || 0), + 0 + ) || 0 + : (hackathon as Hackathon).prizeTiers?.reduce( + (sum: number, tier: any) => sum + (tier.amount || 0), + 0 + ) || 0; if (isDraft) { return (
router.push( - `/organizations/${organizationId}/hackathons/drafts/${hackathon._id}` + `/organizations/${organizationId}/hackathons/drafts/${hackathon.id}` ) } className='group cursor-pointer rounded-xl border border-zinc-800 bg-zinc-900/30 p-6 transition-all hover:border-zinc-700 hover:bg-zinc-900/50' >
- + Draft - - + + {completion}% complete
@@ -418,7 +440,7 @@ export default function HackathonsPage() { onClick={e => { e.stopPropagation(); router.push( - `/hackathons/preview/${organizationId}/${hackathon._id}` + `/hackathons/preview/${organizationId}/${hackathon.id}` ); }} className='flex h-9 w-9 items-center justify-center rounded-lg border border-zinc-800 bg-zinc-900/50 text-zinc-400 opacity-0 transition-all group-hover:opacity-100 hover:border-zinc-700 hover:text-white' @@ -429,7 +451,7 @@ export default function HackathonsPage() { -
- - {/* Campaign Table Section */} -
- -
-
-
- - ); -} diff --git a/app/user/layout.tsx b/app/user/layout.tsx deleted file mode 100644 index bd122c20..00000000 --- a/app/user/layout.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import DashboardInset from '@/components/layout/DashboardInset'; -import SidebarLayout from '@/components/layout/sidebar'; -import { SidebarProvider } from '@/components/ui/sidebar'; - -// Force dynamic rendering for all user pages -export const dynamic = 'force-dynamic'; - -export default async function UserLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( -
- - - {children} - -
- ); -} diff --git a/app/user/page.tsx b/app/user/page.tsx deleted file mode 100644 index 8976de6a..00000000 --- a/app/user/page.tsx +++ /dev/null @@ -1,104 +0,0 @@ -'use client'; -import { PriceDisplay } from '@/components/PriceDisplay'; -import Card from '@/components/card'; -import RecentProjects from '@/components/overview/RecentProjects'; -import PageTransition from '@/components/PageTransition'; -import { Coins, History } from 'lucide-react'; -import { useAuth } from '@/hooks/use-auth'; -import CampaignTable from '@/components/campaigns/CampaignTable'; -import { useEffect, useState } from 'react'; -import { UserPageSkeleton } from '@/components/skeleton/UserPageSkeleton'; - -export default function UserPage() { - const { user, isLoading } = useAuth(); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - - // Show loading until client-side hydration is complete - if (!mounted || isLoading) { - return ; - } - - return ( - -
-
- {/* Header Section */} -
-

- Hello,{' '} - {user?.profile?.firstName || user?.profile?.lastName || 'User'} -

-
- {/* Stats Cards Grid */} -
- - - - No recent submissions - -
- } - /> - - 0 - - Approved Submissions - -
- } - /> - } - bottomText={ -
- 6 grants available -
- } - /> - - - -
- } - /> -
- - {/* Main Content Grid */} -
- {/* Recent Projects - Full Width */} - -
- -
- - {/* Recent Contributions and Grant History - Side by Side on larger screens */} - {/*
- - -
*/} -
-
-
- - ); -} diff --git a/app/user/projects/page.tsx b/app/user/projects/page.tsx deleted file mode 100644 index 791d90d2..00000000 --- a/app/user/projects/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client'; -import PageTransition from '@/components/PageTransition'; -import Projects from '@/components/Projects'; -import React, { useEffect, useState } from 'react'; -import { ProjectsPageSkeleton } from '@/components/skeleton/ProjectsSkeleton'; - -const Page = () => { - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - - // Show loading until client-side hydration is complete - if (!mounted) { - return ; - } - - return ( - -
-
- -
-
-
- ); -}; - -export default Page; diff --git a/components/ProjectCard.tsx b/components/ProjectCard.tsx index 13ab895a..a2d77de4 100644 --- a/components/ProjectCard.tsx +++ b/components/ProjectCard.tsx @@ -1,6 +1,6 @@ 'use client'; import React, { useRef, useCallback, memo } from 'react'; -import { Project } from '@/types/project'; +import { CrowdfundingProject } from '@/types/project'; import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; import { Badge } from './ui/badge'; import gsap from 'gsap'; @@ -9,7 +9,7 @@ import Image from 'next/image'; import { Progress } from './ui/progress'; interface ProjectCardProps { - project: Project; + project: CrowdfundingProject; creatorName?: string; creatorAvatar?: string; daysLeft?: number; @@ -200,7 +200,7 @@ const ProjectCard: React.FC = memo(
{project.name} = memo(

- {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 ( - <> - - -
-
-
- Campaign Summary -
-
-
-
-

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()} -
-
-
-
- {backer.time} -
-
-
- ))} -
-
-
-
-
-
- - ); -}; - -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.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.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 campaigns available -
-

- 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' - /> -
-
- -
- -