diff --git a/.env.example b/.env.example index bd0cd9e2b..8db3c9007 100644 --- a/.env.example +++ b/.env.example @@ -1,53 +1,23 @@ -# Stellar Network Configuration -NEXT_PUBLIC_STELLAR_NETWORK=testnet -# Options: testnet, public - -# Application Configuration -NEXT_PUBLIC_APP_URL=http://localhost:3000 -# Your application's public URL - -# API Configuration -NEXT_PUBLIC_API_URL=http://localhost:8000/api -# Your backend API URL - -# WalletConnect Configuration -NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=your_wallet_connect_project_id -# Get this from https://cloud.walletconnect.com/ - -# Stellar Horizon URLs (optional - defaults will be used) -NEXT_PUBLIC_HORIZON_TESTNET_URL=https://horizon-testnet.stellar.org -NEXT_PUBLIC_HORIZON_PUBLIC_URL=https://horizon.stellar.org - -# Application Metadata -NEXT_PUBLIC_APP_NAME=Boundless -NEXT_PUBLIC_APP_DESCRIPTION=Stellar-based application -NEXT_PUBLIC_APP_ICON=/logo.svg - -# Development Settings -NEXT_PUBLIC_DEBUG_MODE=false -# Enable debug logging for wallet operations - -# Feature Flags -NEXT_PUBLIC_ENABLE_WALLET_CONNECT=true -NEXT_PUBLIC_ENABLE_MULTI_WALLET=true -NEXT_PUBLIC_ENABLE_NETWORK_SWITCHING=true - -# NextAuth Configuration -NEXTAUTH_SECRET=your_nextauth_secret_here -# Generate a random string for this secret -# You can use: openssl rand -base64 32 - -NEXTAUTH_URL=http://localhost:3000 -# Your application's public URL (same as NEXT_PUBLIC_APP_URL) - -# Google OAuth Configuration -GOOGLE_CLIENT_ID=your_google_client_id -# Get this from Google Cloud Console - -GOOGLE_CLIENT_SECRET=your_google_client_secret -# Get this from Google Cloud Console - -# Environment -NODE_ENV=development -# Options: development, production, test - +# Created by Vercel CLI +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="your_google_client_secret" +NEXTAUTH_SECRET="PiRlq+7IyU5QJuCiVwjiuwZUGCqc/3qw1QPgLOt2AFg=" +NEXTAUTH_URL="http://localhost:3000" +NEXT_PUBLIC_API_URL="https://stage-api.boundlessfi.xyz/api" +NEXT_PUBLIC_APP_DESCRIPTION="Stellar-based application" +NEXT_PUBLIC_APP_ICON="/logo.svg" +NEXT_PUBLIC_APP_NAME="Boundless" +NEXT_PUBLIC_APP_URL="http://localhost:3000" +NEXT_PUBLIC_BETTER_AUTH_URL="https://stage-api.boundlessfi.xyz" +NEXT_PUBLIC_BOUNDLESS_PLATFORM_ADDRESS="" +NEXT_PUBLIC_DEBUG_MODE="false" +NEXT_PUBLIC_ENABLE_MULTI_WALLET="true" +NEXT_PUBLIC_ENABLE_NETWORK_SWITCHING="true" +NEXT_PUBLIC_ENABLE_WALLET_CONNECT="true" +NEXT_PUBLIC_GOOGLE_CLIENT_ID="" +NEXT_PUBLIC_HORIZON_PUBLIC_URL="https://horizon.stellar.org" +NEXT_PUBLIC_HORIZON_TESTNET_URL="https://horizon-testnet.stellar.org" +NEXT_PUBLIC_STELLAR_NETWORK="testnet" +NEXT_PUBLIC_TRUSTLESS_WORK_API_KEY="" +NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID="your_wallet_connect_project_id" +NODE_ENV="dev" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53a07fc36..aaa770101 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,7 +123,7 @@ jobs: run: | echo "๐Ÿ”’ Checking for known vulnerabilities..." npm audit --audit-level=moderate --json > audit-report.json - if jq -e '.metadata.vulnerabilities.total > 0' audit-report.json; then + if jq -e '(.metadata.vulnerabilities.moderate + .metadata.vulnerabilities.high + .metadata.vulnerabilities.critical) > 0' audit-report.json; then echo "โŒ Found security vulnerabilities. Please fix them before merging." cat audit-report.json | jq '.metadata.vulnerabilities' exit 1 @@ -158,7 +158,12 @@ jobs: while IFS= read -r commit; do hash=$(echo "$commit" | cut -d' ' -f1) message=$(echo "$commit" | cut -d' ' -f2-) - + + # Skip git-generated merge commits + if echo "$message" | grep -qE "^Merge (branch|pull request|remote-tracking branch|[0-9a-f]{7,40}) "; then + continue + fi + if ! echo "$message" | grep -qE "$commit_regex"; then invalid_commits="$invalid_commits\n$hash: $message" fi diff --git a/.github/workflows/pre-commit-validation.yml b/.github/workflows/pre-commit-validation.yml index a7c115934..1f9e02324 100644 --- a/.github/workflows/pre-commit-validation.yml +++ b/.github/workflows/pre-commit-validation.yml @@ -55,7 +55,11 @@ jobs: invalid_commits="" while IFS= read -r message; do - if [ -n "$message" ] && ! echo "$message" | grep -qE "$commit_regex"; then + # Skip empty lines and git-generated merge commits + if [ -z "$message" ] || echo "$message" | grep -qE "^Merge (branch|pull request|remote-tracking branch|[0-9a-f]{7,40}) "; then + continue + fi + if ! echo "$message" | grep -qE "$commit_regex"; then invalid_commits="$invalid_commits\n$message" fi done <<< "$commits" @@ -85,7 +89,7 @@ jobs: console_log_found=false for file in $changed_files; do - if [[ "$file" =~ \.(ts|tsx|js|jsx)$ ]]; then + if [[ "$file" =~ \.(ts|tsx|js|jsx)$ ]] && [ -f "$file" ]; then if grep -q "console\.log" "$file"; then echo "โŒ Found console.log in $file" console_log_found=true @@ -115,7 +119,7 @@ jobs: todo_found=false for file in $changed_files; do - if [[ "$file" =~ \.(ts|tsx|js|jsx)$ ]]; then + if [[ "$file" =~ \.(ts|tsx|js|jsx)$ ]] && [ -f "$file" ]; then if grep -q "TODO\|FIXME" "$file"; then echo "โš ๏ธ Found TODO/FIXME in $file" todo_found=true diff --git a/.husky/pre-push b/.husky/pre-push index a4447e2ce..a0b25c772 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -8,7 +8,7 @@ echo "๐Ÿš€ Running pre-push checks..." # Run security audit echo "๐Ÿ”’ Running security audit..." -npm audit --audit-level=moderate +npm audit --omit=dev --audit-level=high # Run build check one more time # echo "๐Ÿ—๏ธ Final build check..." diff --git a/COMMENT_SYSTEM_README.md b/COMMENT_SYSTEM_README.md deleted file mode 100644 index e6b5de847..000000000 --- a/COMMENT_SYSTEM_README.md +++ /dev/null @@ -1,571 +0,0 @@ -# ๐Ÿ—ฃ๏ธ 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/Redesign Project Detail Page b/Redesign Project Detail Page new file mode 100644 index 000000000..e6245450b --- /dev/null +++ b/Redesign Project Detail Page @@ -0,0 +1,4 @@ +Completed: Redesign Project Detail Page โ€” Simpler, clearer UI/UX + +https://www.figma.com/design/EMNGAQl1SGObXcsoa24krt/Boundless_Project-Details?node-id=0-1&t=CY7XUp5OWWoJ8qpK-1 + diff --git a/app/(landing)/blog/[slug]/page.tsx b/app/(landing)/blog/[slug]/page.tsx index 6e2557c6f..d7d795d8a 100644 --- a/app/(landing)/blog/[slug]/page.tsx +++ b/app/(landing)/blog/[slug]/page.tsx @@ -2,117 +2,62 @@ import React from 'react'; import { Metadata } from 'next'; import { notFound } from 'next/navigation'; import BlogPostDetails from '@/components/landing-page/blog/BlogPostDetails'; -import { getBlogPost, getBlogPosts } from '@/lib/api/blog'; -import { generateBlogPostMetadata } from '@/lib/metadata'; +import { getAllBlogPosts, getBlogPostBySlug, getRelatedPosts } from '@/lib/mdx'; interface BlogPostPageProps { params: Promise<{ slug: string }>; } -interface StaticParams { - slug: string; -} - -const STATIC_GENERATION_LIMIT = 100; - -export async function generateStaticParams(): Promise { - try { - const response = await getBlogPosts({ - page: 1, - limit: STATIC_GENERATION_LIMIT, - status: 'PUBLISHED', - }); - - const data = response.data; - - if (!data || data.length === 0) { - return []; - } - - return data.map(post => ({ - slug: post.slug, - })); - } catch { - return []; - } +export async function generateStaticParams() { + return getAllBlogPosts().map(post => ({ slug: post.slug })); } export async function generateMetadata({ params, }: BlogPostPageProps): Promise { - try { - const { slug } = await params; - - if (!slug || typeof slug !== 'string') { - return getDefaultMetadata(); - } - - const post = await getBlogPost(slug); - - if (!post) { - return getNotFoundMetadata(); - } - - return generateBlogPostMetadata(post); - } catch { - return getDefaultMetadata(); + const { slug } = await params; + const post = await getBlogPostBySlug(slug); + + if (!post) { + 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 }, + }; } + + return { + title: `${post.title} | Boundless Blog`, + description: post.excerpt, + openGraph: { + title: post.title, + description: post.excerpt, + images: post.coverImage ? [{ url: post.coverImage }] : [], + type: 'article', + publishedTime: post.publishedAt, + authors: [post.author.name], + }, + twitter: { + card: 'summary_large_image', + title: post.title, + description: post.excerpt, + images: post.coverImage ? [post.coverImage] : [], + }, + }; } 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 (error) { - if (error instanceof Error) { - if ( - error.message.includes('404') || - error.message.includes('not found') - ) { - notFound(); - } - } + const { slug } = await params; + const post = await getBlogPostBySlug(slug); + if (!post) { notFound(); } -}; -function getDefaultMetadata(): Metadata { - return { - title: 'Blog Post | Boundless', - description: 'Read our latest blog posts and insights.', - robots: { - index: false, - follow: true, - }, - }; -} + const related = getRelatedPosts(post.slug, post.tags, post.categories); -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, - }, - }; -} + return ; +}; export default BlogPostPage; diff --git a/app/(landing)/blog/page.tsx b/app/(landing)/blog/page.tsx index 317bb67e6..a4053960e 100644 --- a/app/(landing)/blog/page.tsx +++ b/app/(landing)/blog/page.tsx @@ -1,77 +1,15 @@ -'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 { 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); - } - }; +import { getAllBlogPosts } from '@/lib/mdx'; - fetchInitialPosts(); - }, []); - - const handleLoadMore = async ( - page: number - ): Promise => { - const response = await getBlogPosts({ - page, - limit: 12, - sortBy: 'createdAt', - sortOrder: 'desc', - status: 'PUBLISHED', - }); - return response; - }; +const BlogsPage = async () => { + const posts = getAllBlogPosts(); return (
- - {isLoading ? ( -
-
- - Loading articles... -
-
- ) : error ? ( -
-

{error}

-
- ) : blogData ? ( - - ) : null} +
); diff --git a/app/(landing)/bounties/page.tsx b/app/(landing)/bounties/page.tsx index c303997a7..7b2083349 100644 --- a/app/(landing)/bounties/page.tsx +++ b/app/(landing)/bounties/page.tsx @@ -1,10 +1,11 @@ -import React from 'react'; +import { redirect } from 'next/navigation'; import { Metadata } from 'next'; import { generatePageMetadata } from '@/lib/metadata'; export const metadata: Metadata = generatePageMetadata('grants'); const GrantPage = () => { + redirect('/coming-soon'); return (
Bounties Page diff --git a/app/(landing)/contact/page.tsx b/app/(landing)/contact/page.tsx index 0e253034a..d12c5373d 100644 --- a/app/(landing)/contact/page.tsx +++ b/app/(landing)/contact/page.tsx @@ -1,10 +1,11 @@ -import React from 'react'; +import { redirect } from 'next/navigation'; import { Metadata } from 'next'; import { generatePageMetadata } from '@/lib/metadata'; export const metadata: Metadata = generatePageMetadata('contact'); const ContactPage = () => { + redirect('/coming-soon'); return (
Contact Page diff --git a/app/(landing)/grants/page.tsx b/app/(landing)/grants/page.tsx index 08dfe9250..1c3258e95 100644 --- a/app/(landing)/grants/page.tsx +++ b/app/(landing)/grants/page.tsx @@ -1,10 +1,11 @@ -import React from 'react'; +import { redirect } from 'next/navigation'; import { Metadata } from 'next'; import { generatePageMetadata } from '@/lib/metadata'; export const metadata: Metadata = generatePageMetadata('grants'); const GrantPage = () => { + redirect('/coming-soon'); return (
Grant Page diff --git a/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx b/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx new file mode 100644 index 000000000..91518cd5a --- /dev/null +++ b/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx @@ -0,0 +1,521 @@ +'use client'; + +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useRouter, useSearchParams, useParams } from 'next/navigation'; +import { useHackathonData } from '@/lib/providers/hackathonProvider'; +import { useRegisterHackathon } from '@/hooks/hackathon/use-register-hackathon'; +import { useLeaveHackathon } from '@/hooks/hackathon/use-leave-hackathon'; +import { RegisterHackathonModal } from '@/components/hackathons/overview/RegisterHackathonModal'; +import { HackathonBanner } from '@/components/hackathons/hackathonBanner'; +import { HackathonNavTabs } from '@/components/hackathons/hackathonNavTabs'; +import { HackathonOverview } from '@/components/hackathons/overview/hackathonOverview'; +import { HackathonResources } from '@/components/hackathons/resources/resources'; +import SubmissionTab from '@/components/hackathons/submissions/submissionTab'; +import { HackathonDiscussions } from '@/components/hackathons/discussion/comment'; +import { TeamFormationTab } from '@/components/hackathons/team-formation/TeamFormationTab'; +import { WinnersTab } from '@/components/hackathons/winners/WinnersTab'; +import LoadingScreen from '@/features/projects/components/CreateProjectModal/LoadingScreen'; +import { useTimelineEvents } from '@/hooks/hackathon/use-timeline-events'; +import { toast } from 'sonner'; +import type { Participant } from '@/lib/api/hackathons'; +import { HackathonStickyCard } from '@/components/hackathons/hackathonStickyCard'; +import { HackathonParticipants } from '@/components/hackathons/participants/hackathonParticipant'; +import { useCommentSystem } from '@/hooks/use-comment-system'; +import { CommentEntityType } from '@/types/comment'; +import { useTeamPosts } from '@/hooks/hackathon/use-team-posts'; +import { + listAnnouncements, + type HackathonAnnouncement, +} from '@/lib/api/hackathons/index'; +import { Megaphone } from 'lucide-react'; +import { AnnouncementsTab } from '@/components/hackathons/announcements/AnnouncementsTab'; + +export default function HackathonPageClient() { + const router = useRouter(); + const searchParams = useSearchParams(); + const params = useParams(); + + const { + currentHackathon, + submissions, + winners, + loading, + setCurrentHackathon, + refreshCurrentHackathon, + } = useHackathonData(); + + const timeline_Events = useTimelineEvents(currentHackathon, { + includeEndDate: false, + dateFormat: { month: 'short', day: 'numeric', year: 'numeric' }, + }); + + const hackathonId = params.slug as string; + const [activeTab, setActiveTab] = useState('overview'); + const [showRegisterModal, setShowRegisterModal] = useState(false); + + // Fetch discussion comments for count + const { comments: discussionComments } = useCommentSystem({ + entityType: CommentEntityType.HACKATHON, + entityId: hackathonId, + page: 1, + limit: 100, + enabled: !!hackathonId, + }); + + // Fetch team posts for count + const { posts: teamPosts } = useTeamPosts({ + hackathonSlugOrId: hackathonId, + autoFetch: !!hackathonId, + }); + + // Fetch announcements for public view + const [announcements, setAnnouncements] = useState( + [] + ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [announcementsLoading, setAnnouncementsLoading] = useState(false); + + useEffect(() => { + async function fetchAnnouncements() { + if (!hackathonId) return; + try { + setAnnouncementsLoading(true); + const data = await listAnnouncements(hackathonId); + // Only show published announcements for public view + setAnnouncements(data.filter(a => !a.isDraft)); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to fetch announcements:', error); + } finally { + setAnnouncementsLoading(false); + } + } + fetchAnnouncements(); + }, [hackathonId]); + + const hackathonTabs = useMemo(() => { + const hasParticipants = + Array.isArray(currentHackathon?.participants) && + currentHackathon.participants.length > 0; + + const hasResources = currentHackathon?.resources?.[0]; + const participantType = currentHackathon?.participantType; + const isTeamHackathon = + participantType === 'TEAM' || participantType === 'TEAM_OR_INDIVIDUAL'; + + const hasWinners = winners && winners.length > 0; + + const tabs = [ + { id: 'overview', label: 'Overview' }, + ...(hasParticipants + ? [ + { + id: 'participants', + label: 'Participants', + badge: currentHackathon?.participants.length, + }, + ] + : []), + ...(hasResources + ? [ + { + id: 'resources', + label: 'Resources', + badge: currentHackathon?.resources?.length, + }, + ] + : []), + ...(announcements.length > 0 + ? [ + { + id: 'announcements', + label: 'Announcements', + badge: announcements.length, + icon: Megaphone, + }, + ] + : []), + { + id: 'submission', + label: 'Submissions', + badge: submissions.filter(p => p.status === 'Approved').length, + }, + { + id: 'discussions', + label: 'Discussions', + badge: discussionComments.comments.length, + }, + ]; + + if (isTeamHackathon) { + tabs.push({ + id: 'team-formation', + label: 'Find Team', + badge: teamPosts.length, + }); + } + + if (hasWinners) { + tabs.push({ + id: 'winners', + label: 'Winners', + badge: winners.length, + }); + } + + // Filter tabs against enabledTabs so only explicitly enabled tabs are shown. + // 'overview' is always kept as it is the default fallback tab. + // If enabledTabs is undefined/null (not configured), all tabs are shown as before. + // + // IMPORTANT: Any new tab id added to the tabs array above must have a corresponding + // entry in tabIdToEnabledKey below; otherwise it falls back to tab.id and may be + // hidden when enabledTabs is set. + const tabIdToEnabledKey: Record = { + 'team-formation': 'joinATeamTab', + winners: 'winnersTab', + resources: 'resourcesTab', + participants: 'participantsTab', + announcements: 'announcementsTab', + submission: 'submissionTab', + discussions: 'discussionTab', + }; + + /** Backend enabledTabs entry type; keys in tabIdToEnabledKey must align with this. */ + type EnabledTab = NonNullable< + typeof currentHackathon + >['enabledTabs'][number]; + const enabledTabs = currentHackathon?.enabledTabs; + + if (Array.isArray(enabledTabs)) { + const enabledSet = new Set(enabledTabs); + return tabs.filter(tab => { + if (tab.id === 'overview') return true; + const key = (tabIdToEnabledKey[tab.id] ?? tab.id) as EnabledTab; + const isVisible = enabledSet.has(key); + if ( + !isVisible && + process.env.NODE_ENV === 'development' && + currentHackathon?.enabledTabs + ) { + console.warn( + `[HackathonPageClient] Tab "${tab.id}" (enabled key: ${key}) is not in currentHackathon.enabledTabs and will be hidden. Add the tab id to tabIdToEnabledKey and ensure the backend includes the key in enabledTabs when the tab should be visible.` + ); + } + return isVisible; + }); + } + return tabs; + }, [ + currentHackathon?.participants, + currentHackathon?.resources, + currentHackathon?.participantType, + currentHackathon?.enabledTabs, + submissions, + discussionComments.comments.length, + teamPosts.length, + winners, + announcements, + ]); + + // Refresh hackathon data + const refreshHackathonData = useCallback(async () => { + if (hackathonId && refreshCurrentHackathon) { + await refreshCurrentHackathon(); + } + }, [hackathonId, refreshCurrentHackathon]); + + // Registration status + const { + isRegistered, + hasSubmitted, + setParticipant, + register: registerForHackathon, + } = useRegisterHackathon({ + hackathon: currentHackathon + ? { + id: currentHackathon.id, + slug: currentHackathon.slug, + isParticipant: currentHackathon.isParticipant, + } + : null, + organizationId: undefined, + }); + + // Leave hackathon functionality + const { isLeaving, leave: leaveHackathon } = useLeaveHackathon({ + hackathonSlugOrId: currentHackathon?.id || '', + organizationId: undefined, + }); + + const handleRegister = async () => { + try { + const participantData = await registerForHackathon(); + + toast.success('Successfully registered for hackathon!'); + handleRegisterSuccess(participantData); + } catch { + // Error handled in hook + } + }; + + // Team formation availability + const isTeamHackathon = + currentHackathon?.participantType === 'TEAM' || + currentHackathon?.participantType === 'TEAM_OR_INDIVIDUAL'; + const isTeamFormationEnabled = + isTeamHackathon && + currentHackathon?.enabledTabs?.includes('joinATeamTab') !== false; + + // Event handlers + const handleJoinClick = () => { + handleRegister(); + }; + + const handleLeaveClick = async () => { + try { + setParticipant(null); + await leaveHackathon(); + refreshHackathonData(); + router.push('?tab=overview'); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to leave hackathon'; + toast.error(errorMessage); + } + }; + + const handleRegisterSuccess = async (participantData: Participant) => { + setParticipant(participantData); + await refreshHackathonData(); + router.push('?tab=submission'); + }; + + const handleSubmitClick = () => { + router.push('?tab=submission'); + }; + + const handleViewSubmissionClick = () => { + router.push('?tab=submission'); + }; + + const handleFindTeamClick = () => { + router.push('?tab=team-formation'); + }; + + // Set current hackathon on mount + useEffect(() => { + if (hackathonId) { + setCurrentHackathon(hackathonId); + } + }, [hackathonId, setCurrentHackathon]); + + // Handle tab changes from URL + // Now also defaults to 'overview' if the URL tab is not in the filtered hackathonTabs list. + // This handles direct URL access to a disabled tab โ€” user is silently redirected to overview. + useEffect(() => { + if (loading || !currentHackathon) return; + + const tabFromUrl = searchParams.get('tab'); + + // No tab in URL โ€” default to overview + if (!tabFromUrl) { + setActiveTab('overview'); + return; + } + + if (hackathonTabs.some(tab => tab.id === tabFromUrl)) { + // Tab exists in filtered list โ€” activate it normally + setActiveTab(tabFromUrl); + return; + } + + // Tab is disabled or unrecognised โ€” fall back to overview + setActiveTab('overview'); + const queryParams = new URLSearchParams(searchParams.toString()); + queryParams.set('tab', 'overview'); + router.replace(`?${queryParams.toString()}`, { scroll: false }); + }, [searchParams, hackathonTabs, router, loading, currentHackathon]); + + const handleTabChange = (tabId: string) => { + setActiveTab(tabId); + const queryParams = new URLSearchParams(searchParams.toString()); + queryParams.set('tab', tabId); + router.push(`?${queryParams.toString()}`, { scroll: false }); + }; + + // Loading state + if (loading) { + return ; + } + + // Hackathon not found + if (!currentHackathon) { + return ( +
+
+

+ Hackathon not found +

+

+ The hackathon you're looking for doesn't exist. +

+
+
+ ); + } + + // Helper: checks if a tab id is present in the filtered hackathonTabs array. + // Used below to guard each tab's content from rendering if the tab is disabled. + const isTabVisible = (tabId: string): boolean => + hackathonTabs.some(tab => tab.id === tabId); + + // Shared props for banner and sticky card + const sharedActionProps = { + deadline: currentHackathon.submissionDeadline, + startDate: currentHackathon.startDate, + totalPrizePool: currentHackathon.prizeTiers + .reduce((acc, prize) => acc + Number(prize.prizeAmount || 0), 0) + .toString(), + isRegistered, + hasSubmitted, + isTeamFormationEnabled, + registrationDeadline: currentHackathon.registrationDeadline, + participantType: currentHackathon.participantType, + onJoinClick: handleJoinClick, + onLeaveClick: handleLeaveClick, + isLeaving, + onSubmitClick: handleSubmitClick, + onViewSubmissionClick: handleViewSubmissionClick, + onFindTeamClick: handleFindTeamClick, + }; + + return ( +
+
+
+ + + + +
+ {activeTab === 'overview' && ( + ({ + id: tier.id, + place: tier.name, + currency: tier.currency, + passMark: tier.passMark, + description: tier.description, + prizeAmount: tier.prizeAmount, + }))} + totalPrizePool={currentHackathon.prizeTiers + .reduce( + (acc, prize) => acc + Number(prize.prizeAmount || 0), + 0 + ) + .toString()} + hackathonSlugOrId={hackathonId} + venue={{ + type: currentHackathon.venueType.toLowerCase() as + | 'virtual' + | 'physical', + country: currentHackathon.country, + state: currentHackathon.state, + city: currentHackathon.city, + venueName: currentHackathon.venueName, + venueAddress: currentHackathon.venueAddress, + }} + /> + )} + + {/* isTabVisible('resources') guard โ€” HackathonResources will not + render at all if 'resources' is not in enabledTabs, even via direct URL */} + {activeTab === 'resources' && + isTabVisible('resources') && + currentHackathon.resources?.length > 0 && } + + {/* isTabVisible('participants') guard */} + {activeTab === 'participants' && + isTabVisible('participants') && + currentHackathon.participants?.length > 0 && ( + + )} + + {/* isTabVisible('announcements') guard */} + {activeTab === 'announcements' && + isTabVisible('announcements') && + announcements.length > 0 && ( + + )} + + {/* isTabVisible('submission') guard */} + {activeTab === 'submission' && isTabVisible('submission') && ( + + )} + + {/* isTabVisible('discussions') guard */} + {activeTab === 'discussions' && isTabVisible('discussions') && ( + + )} + + {/* isTabVisible('team-formation') guard */} + {activeTab === 'team-formation' && + isTabVisible('team-formation') && ( + + )} + + {/* isTabVisible('winners') guard */} + {activeTab === 'winners' && isTabVisible('winners') && ( + + )} + + {/* Note: duplicate resources render removed โ€” was already covered above */} +
+
+ +
+ +
+
+ + {hackathonId && ( + + )} +
+ ); +} diff --git a/app/(landing)/hackathons/[slug]/announcements/[announcementId]/page.tsx b/app/(landing)/hackathons/[slug]/announcements/[announcementId]/page.tsx new file mode 100644 index 000000000..759781a56 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/announcements/[announcementId]/page.tsx @@ -0,0 +1,199 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { Megaphone, ArrowLeft, Calendar, User, Pin } from 'lucide-react'; +import { + getAnnouncementDetails, + GetHackathonBySlug, + type HackathonAnnouncement, +} from '@/lib/api/hackathons/index'; +import { useMarkdown } from '@/hooks/use-markdown'; +import { BoundlessButton } from '@/components/buttons'; +import Loading from '@/components/Loading'; +import { Badge } from '@/components/ui/badge'; + +export default function AnnouncementDetailPage() { + const params = useParams(); + const router = useRouter(); + const announcementId = params.announcementId as string; + const slug = params.slug as string; + + const [announcement, setAnnouncement] = + useState(null); + const [hackathonName, setHackathonName] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchDetails() { + if (!announcementId) return; + try { + setLoading(true); + const [announcementData, hackathonData] = await Promise.all([ + getAnnouncementDetails(announcementId), + GetHackathonBySlug(slug), + ]); + setAnnouncement(announcementData); + setHackathonName(hackathonData.data.name); + } catch (err) { + console.error('Failed to fetch details:', err); + setError( + 'Failed to load announcement. It may have been deleted or moved.' + ); + } finally { + setLoading(false); + } + } + fetchDetails(); + }, [announcementId, slug]); + + const { styledContent, loading: markdownLoading } = useMarkdown( + announcement?.content || '', + { + breaks: true, + gfm: true, + } + ); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error || !announcement) { + return ( +
+
+ +
+

+ Announcement Not Found +

+

+ {error || "We couldn't find the announcement you're looking for."} +

+ router.back()} variant='outline'> + + Go Back + +
+ ); + } + + return ( +
+ {/* Top Header */} +
+
+ +
+ + + {hackathonName ? `${hackathonName} โ€ข ` : ''}Announcement + +
+
+
+ +
+ {/* Title & Metadata */} +
+
+ {hackathonName && ( + + {hackathonName} + + )} + {announcement.isPinned && ( + + + Pinned + + )} + + Published + +
+ +

+ {announcement.title} +

+ +
+
+
+ {announcement.author?.name?.charAt(0) || ( + + )} +
+ + {announcement.author?.name || 'Organizer'} + +
+ +
+ + + {new Date( + announcement.publishedAt || announcement.createdAt + ).toLocaleDateString(undefined, { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + })} + +
+
+
+ + {/* Content */} +
+ {markdownLoading ? ( +
+
+
+ ) : ( +
+ {styledContent} +
+ )} +
+ + {/* Footer */} +
+

End of Announcement

+

+ This announcement was published by the hackathon organizers. +

+ window.close()} + variant='outline' + size='sm' + > + Return to Hackathon + +
+
+
+ ); +} diff --git a/app/(landing)/hackathons/[slug]/page.tsx b/app/(landing)/hackathons/[slug]/page.tsx index 38a0e2d62..6caeb1d7e 100644 --- a/app/(landing)/hackathons/[slug]/page.tsx +++ b/app/(landing)/hackathons/[slug]/page.tsx @@ -1,427 +1,52 @@ -'use client'; - -import { useState, useEffect, useMemo, useCallback } from 'react'; -import { useRouter, useSearchParams, useParams } from 'next/navigation'; -import { useHackathonData } from '@/lib/providers/hackathonProvider'; -import { useRegisterHackathon } from '@/hooks/hackathon/use-register-hackathon'; -import { useLeaveHackathon } from '@/hooks/hackathon/use-leave-hackathon'; -import { RegisterHackathonModal } from '@/components/hackathons/overview/RegisterHackathonModal'; -import { HackathonBanner } from '@/components/hackathons/hackathonBanner'; -import { HackathonNavTabs } from '@/components/hackathons/hackathonNavTabs'; -import { HackathonOverview } from '@/components/hackathons/overview/hackathonOverview'; -import { HackathonResources } from '@/components/hackathons/resources/resources'; -import SubmissionTab from '@/components/hackathons/submissions/submissionTab'; -import { HackathonDiscussions } from '@/components/hackathons/discussion/comment'; -import { TeamFormationTab } from '@/components/hackathons/team-formation/TeamFormationTab'; -import { WinnersTab } from '@/components/hackathons/winners/WinnersTab'; -import LoadingScreen from '@/features/projects/components/CreateProjectModal/LoadingScreen'; -import { useTimelineEvents } from '@/hooks/hackathon/use-timeline-events'; -import { toast } from 'sonner'; -import type { Participant } from '@/lib/api/hackathons'; -import { HackathonStickyCard } from '@/components/hackathons/hackathonStickyCard'; -import { HackathonParticipants } from '@/components/hackathons/participants/hackathonParticipant'; -import { useCommentSystem } from '@/hooks/use-comment-system'; -import { CommentEntityType } from '@/types/comment'; -import { useTeamPosts } from '@/hooks/hackathon/use-team-posts'; -import { HackathonWinner } from '@/lib/api/hackathons'; - -export default function HackathonPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const params = useParams(); - - const { - currentHackathon, - submissions, - winners, - loading, - setCurrentHackathon, - refreshCurrentHackathon, - } = useHackathonData(); - - const timeline_Events = useTimelineEvents(currentHackathon, { - includeEndDate: false, - dateFormat: { month: 'short', day: 'numeric', year: 'numeric' }, - }); - - const hackathonId = params.slug as string; - const [activeTab, setActiveTab] = useState('overview'); - const [showRegisterModal, setShowRegisterModal] = useState(false); - - // Fetch discussion comments for count - const { comments: discussionComments } = useCommentSystem({ - entityType: CommentEntityType.HACKATHON, - entityId: hackathonId, - page: 1, - limit: 100, - enabled: !!hackathonId, - }); - - // Fetch team posts for count - const { posts: teamPosts } = useTeamPosts({ - hackathonSlugOrId: hackathonId, - autoFetch: !!hackathonId, - }); - - const hackathonTabs = useMemo(() => { - const hasParticipants = - Array.isArray(currentHackathon?.participants) && - currentHackathon.participants.length > 0; - - const hasResources = currentHackathon?.resources?.[0]; - const participantType = currentHackathon?.participantType; - const isTeamHackathon = - participantType === 'TEAM' || - participantType === 'TEAM_OR_INDIVIDUAL' || - participantType === 'INDIVIDUAL'; - const isTabEnabled = - currentHackathon?.enabledTabs?.includes('joinATeamTab') !== false; - - // For testing: Use mock winners if real winners are empty - // const displayWinners = - // winners && winners.length > 0 ? winners : MOCK_WINNERS; - // const hasWinners = displayWinners.length > 0; - const hasWinners = winners && winners.length > 0; - - // For testing: Force enable winners tab - // const isWinnersTabEnabled = - // currentHackathon?.enabledTabs?.includes('winnersTab') !== false; - // const isWinnersTabEnabled = true; - const isWinnersTabEnabled = - currentHackathon?.enabledTabs?.includes('winnersTab') !== false; - - const tabs = [ - { id: 'overview', label: 'Overview' }, - ...(hasParticipants - ? [ - { - id: 'participants', - label: 'Participants', - badge: currentHackathon?.participants.length, - }, - ] - : []), - ...(hasResources - ? [ - { - id: 'resources', - label: 'Resources', - badge: currentHackathon?.resources?.length, - }, - ] - : []), - { - id: 'submission', - label: 'Submissions', - badge: submissions.filter(p => p.status === 'Approved').length, - }, - { - id: 'discussions', - label: 'Discussions', - badge: discussionComments.comments.length, - }, - ]; - - if (isTeamHackathon && isTabEnabled) { - tabs.push({ - id: 'team-formation', - label: 'Find Team', - badge: teamPosts.length, - }); - } - - if (hasWinners && isWinnersTabEnabled) { - tabs.push({ - id: 'winners', - label: 'Winners', - }); - } - - return tabs; - }, [ - currentHackathon?.participants, - currentHackathon?.resources, - currentHackathon?.participantType, - currentHackathon?.enabledTabs, - currentHackathon?.organizationId, - submissions, - discussionComments.comments.length, - teamPosts.length, - hackathonId, - winners, - ]); - - // Refresh hackathon data - const refreshHackathonData = useCallback(async () => { - if (hackathonId && refreshCurrentHackathon) { - await refreshCurrentHackathon(); - } - }, [hackathonId, refreshCurrentHackathon]); - - // Registration status - const { - isRegistered, - hasSubmitted, - setParticipant, - register: registerForHackathon, - } = useRegisterHackathon({ - hackathon: currentHackathon - ? { - id: currentHackathon.id, - slug: currentHackathon.slug, - isParticipant: currentHackathon.isParticipant, - } - : null, - organizationId: undefined, - }); - - // Leave hackathon functionality - const { isLeaving, leave: leaveHackathon } = useLeaveHackathon({ - hackathonSlugOrId: currentHackathon?.id || '', - organizationId: undefined, - }); - const handleRegister = async () => { - try { - const participantData = await registerForHackathon(); - - toast.success('Successfully registered for hackathon!'); - handleRegisterSuccess(participantData); - } catch { - // Error handled in hook - } - }; - - // Check if hackathon has ended - // const isEnded = useMemo(() => { - // if (!currentHackathon?.deadline) return false - // const deadline = new Date(currentHackathon.deadline) - // const now = new Date() - // return now > deadline - // }, [currentHackathon?.deadline]) - - // Team formation availability - const isTeamHackathon = - currentHackathon?.participantType === 'TEAM' || - currentHackathon?.participantType === 'TEAM_OR_INDIVIDUAL' || - currentHackathon?.participantType === 'INDIVIDUAL'; - const isTeamFormationEnabled = - isTeamHackathon && - currentHackathon?.enabledTabs?.includes('joinATeamTab') !== false; - - // Event handlers - const handleJoinClick = () => { - handleRegister(); - }; +import { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { getHackathon } from '@/lib/api/hackathon'; +import { generateHackathonMetadata } from '@/lib/metadata'; +import { HackathonDataProvider } from '@/lib/providers/hackathonProvider'; +import HackathonPageClient from './HackathonPageClient'; + +interface HackathonPageProps { + params: Promise<{ slug: string }>; +} - const handleLeaveClick = async () => { - try { - setParticipant(null); - await leaveHackathon(); - refreshHackathonData(); - router.push('?tab=overview'); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Failed to leave hackathon'; - toast.error(errorMessage); - // refreshHackathonData() will update the isParticipant status +export async function generateMetadata({ + params, +}: HackathonPageProps): Promise { + try { + const { slug } = await params; + const response = await getHackathon(slug); + + if (!response.success || !response.data) { + return { + title: 'Hackathon Not Found | Boundless', + description: 'The requested hackathon could not be found.', + }; } - }; - - const handleRegisterSuccess = async (participantData: Participant) => { - setParticipant(participantData); - await refreshHackathonData(); // This will update hackathon.isParticipant - router.push('?tab=submission'); - }; - - const handleSubmitClick = () => { - router.push('?tab=submission'); - }; - - const handleViewSubmissionClick = () => { - router.push('?tab=submission'); - }; - const handleFindTeamClick = () => { - router.push('?tab=team-formation'); - }; + return generateHackathonMetadata(response.data); + } catch { + return { + title: 'Hackathon | Boundless', + description: 'Join exciting hackathons on Boundless.', + }; + } +} - // Set current hackathon on mount - useEffect(() => { - if (hackathonId) { - setCurrentHackathon(hackathonId); - } - }, [hackathonId, setCurrentHackathon]); +export default async function HackathonPage({ params }: HackathonPageProps) { + const { slug } = await params; + try { + const response = await getHackathon(slug); - // Handle tab changes from URL - useEffect(() => { - const tabFromUrl = searchParams.get('tab'); - if (tabFromUrl && hackathonTabs.some(tab => tab.id === tabFromUrl)) { - setActiveTab(tabFromUrl); + if (!response.success || !response.data) { + notFound(); } - }, [searchParams, hackathonTabs]); - - const handleTabChange = (tabId: string) => { - setActiveTab(tabId); - const params = new URLSearchParams(searchParams.toString()); - params.set('tab', tabId); - router.push(`?${params.toString()}`, { scroll: false }); - }; - - // Loading state - if (loading) { - return ; - } - // Hackathon not found - if (!currentHackathon) { return ( -
-
-

- Hackathon not found -

-

- The hackathon you're looking for doesn't exist. -

-
-
+ + + ); + } catch { + notFound(); } - - // Shared props for banner and sticky card - const sharedActionProps = { - deadline: currentHackathon.submissionDeadline, - startDate: currentHackathon.startDate, - totalPrizePool: currentHackathon.prizeTiers - .reduce((acc, prize) => acc + Number(prize.prizeAmount || 0), 0) - .toString(), - isRegistered, - hasSubmitted, - isTeamFormationEnabled, - registrationDeadlinePolicy: currentHackathon.registrationDeadlinePolicy, // Now matches API casing - registrationDeadline: currentHackathon.registrationDeadline, - onJoinClick: handleJoinClick, - onLeaveClick: handleLeaveClick, - isLeaving, - onSubmitClick: handleSubmitClick, - onViewSubmissionClick: handleViewSubmissionClick, - onFindTeamClick: handleFindTeamClick, - }; - - return ( -
-
- {/* Main Content (2/3 width on desktop) */} -
- {/* Banner - Shows on all screens */} - - - {/* Navigation Tabs */} - - - {/* Tab Content */} -
- {activeTab === 'overview' && ( - ({ - id: tier.id, - place: tier.place, - currency: tier.currency, - passMark: tier.passMark, - description: tier.description, - prizeAmount: tier.prizeAmount, // Keep as string to match PrizeTier interface - }))} - totalPrizePool={currentHackathon.prizeTiers - .reduce( - (acc, prize) => acc + Number(prize.prizeAmount || 0), - 0 - ) - .toString()} - hackathonSlugOrId={hackathonId} - venue={{ - type: currentHackathon.venueType.toLowerCase() as - | 'virtual' - | 'physical', - country: currentHackathon.country, - state: currentHackathon.state, - city: currentHackathon.city, - venueName: currentHackathon.venueName, - venueAddress: currentHackathon.venueAddress, - }} - /> - )} - - {activeTab === 'resources' && - currentHackathon.resources?.length > 0 && } - {activeTab === 'participants' && - currentHackathon.participants?.length > 0 && ( - - )} - - {activeTab === 'submission' && ( - - )} - - {activeTab === 'discussions' && ( - - )} - - {activeTab === 'team-formation' && ( - - )} - - {activeTab === 'winners' && ( - - )} - - {activeTab === 'resources' && currentHackathon?.resources?.[0] && ( - - )} -
-
- - {/* Sidebar - Sticky Card (1/3 width on desktop, hidden on mobile) */} -
- -
-
- - {/* Registration Modal */} - {hackathonId && ( - - )} -
- ); } diff --git a/app/(landing)/hackathons/preview/[orgId]/[draftId]/page.tsx b/app/(landing)/hackathons/preview/[orgId]/[draftId]/page.tsx index 2cc8283af..558a16372 100644 --- a/app/(landing)/hackathons/preview/[orgId]/[draftId]/page.tsx +++ b/app/(landing)/hackathons/preview/[orgId]/[draftId]/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { ArrowLeft } from 'lucide-react'; import { getDraft, PrizeTier, VenueType } from '@/lib/api/hackathons'; +import { getOrganization } from '@/lib/api/organization'; import { HackathonBanner } from '@/components/hackathons/hackathonBanner'; import { HackathonNavTabs } from '@/components/hackathons/hackathonNavTabs'; import { HackathonOverview } from '@/components/hackathons/overview/hackathonOverview'; @@ -93,6 +94,18 @@ export default function DraftPreviewPage({ params }: PreviewPageProps) { resolvedParams.orgId, resolvedParams.draftId ); + let organizationData = { name: '', logo: '' }; + try { + const orgRes = await getOrganization(resolvedParams.orgId); + if (orgRes) { + organizationData = { + name: orgRes.name, + logo: orgRes.logo || '', + }; + } + } catch (orgErr) { + console.error('Failed to fetch organization for preview:', orgErr); + } if (response.success && response.data) { const draft = response.data; @@ -111,8 +124,8 @@ export default function DraftPreviewPage({ params }: PreviewPageProps) { organizationId: resolvedParams.orgId, organization: { id: resolvedParams.orgId, - name: '', // We don't have organizer name from draft - logo: '', + name: organizationData.name, + logo: organizationData.logo, }, status: 'DRAFT', @@ -132,22 +145,14 @@ export default function DraftPreviewPage({ params }: PreviewPageProps) { timezone: draft.data.timeline?.timezone || 'UTC', startDate: draft.data.timeline?.startDate || '', - endDate: draft.data.timeline?.winnerAnnouncementDate || '', submissionDeadline: draft.data.timeline?.submissionDeadline || '', registrationDeadline: - draft.data.participation?.registrationDeadline || '', - customRegistrationDeadline: - draft.data.participation?.registrationDeadline || null, + draft.data.timeline?.registrationDeadline || '', + judgingDeadline: draft.data.timeline?.judgingDeadline || '', registrationOpen: false, - registrationDeadlinePolicy: - (draft.data.participation?.registrationDeadlinePolicy as - | 'BEFORE_START' - | 'BEFORE_SUBMISSION_DEADLINE' - | 'CUSTOM') || 'BEFORE_SUBMISSION_DEADLINE', daysUntilStart: 0, - daysUntilEnd: 0, participantType: (draft.data.participation?.participantType === 'individual' @@ -500,7 +505,6 @@ export default function DraftPreviewPage({ params }: PreviewPageProps) { participants={0} imageUrl={previewHackathon.banner} startDate={previewHackathon.startDate} - endDate={previewHackathon.endDate} /> {/* Tabs */} diff --git a/app/(landing)/org/[slug]/loading.tsx b/app/(landing)/org/[slug]/loading.tsx new file mode 100644 index 000000000..51ad4c26d --- /dev/null +++ b/app/(landing)/org/[slug]/loading.tsx @@ -0,0 +1,42 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function OrgProfileLoading() { + return ( +
+ {/* Banner skeleton */} +
+
+ +
+ + +
+ + +
+
+ {[1, 2, 3].map(i => ( + + ))} +
+
+
+
+ + {/* Stats grid skeleton */} +
+ {[1, 2, 3, 4].map(i => ( + + ))} +
+ + {/* About skeleton */} +
+ + + + +
+
+ ); +} diff --git a/app/(landing)/org/[slug]/org-profile-client.tsx b/app/(landing)/org/[slug]/org-profile-client.tsx new file mode 100644 index 000000000..e57061982 --- /dev/null +++ b/app/(landing)/org/[slug]/org-profile-client.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Image from 'next/image'; +import { + Trophy, + HandCoins, + FolderKanban, + Target, + Building2, +} from 'lucide-react'; +import { + getOrganizationProfile, + OrganizationProfile, +} from '@/lib/api/organization'; +import { Skeleton } from '@/components/ui/skeleton'; + +interface OrgProfileClientProps { + slug: string; +} + +export default function OrgProfileClient({ slug }: OrgProfileClientProps) { + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function loadProfile() { + try { + const data = await getOrganizationProfile(slug); + setProfile(data); + } catch { + setProfile(null); + } finally { + setLoading(false); + } + } + + loadProfile(); + }, [slug]); + + if (loading) { + return ; + } + + if (!profile) { + return ( +
+

Organization not found

+
+ ); + } + + const { stats } = profile; + const statCards = [ + { + label: 'Projects', + value: stats.projectsCount, + icon: FolderKanban, + color: 'text-purple-400', + bgColor: 'bg-purple-500/10', + }, + { + label: 'Hackathons', + value: stats.totalHackathons, + icon: Trophy, + color: 'text-[#a7f950]', + bgColor: 'bg-[#a7f950]/10', + }, + { + label: 'Bounties', + value: stats.totalBounties, + icon: Target, + color: 'text-amber-400', + bgColor: 'bg-amber-500/10', + }, + { + label: 'Grants', + value: stats.totalGrants, + icon: HandCoins, + color: 'text-blue-400', + bgColor: 'bg-blue-500/10', + }, + ]; + + return ( +
+ {/* Banner / Header */} +
+
+
+ +
+
+ {/* Logo */} +
+ {profile.logoUrl ? ( + {profile.name} + ) : ( + + )} +
+ + {/* Info */} +
+

+ {profile.name} +

+ {profile.description && ( +

+ {profile.description} +

+ )} +
+
+
+
+ + {/* Stats Grid */} +
+ {statCards.map((stat, index) => { + const Icon = stat.icon; + return ( +
+
+ +
+
+ {stat.value} +
+ {stat.label} +
+ ); + })} +
+
+ ); +} + +function OrgProfileSkeleton() { + return ( +
+
+
+ +
+ + +
+
+
+ +
+ {[1, 2, 3, 4].map(i => ( + + ))} +
+
+ ); +} diff --git a/app/(landing)/org/[slug]/page.tsx b/app/(landing)/org/[slug]/page.tsx new file mode 100644 index 000000000..b4dedd803 --- /dev/null +++ b/app/(landing)/org/[slug]/page.tsx @@ -0,0 +1,62 @@ +import { Metadata } from 'next'; +import { getOrganizationProfile } from '@/lib/api/organization'; +import OrgProfileClient from './org-profile-client'; + +const SITE_URL = process.env.NEXT_PUBLIC_APP_URL || 'https://boundlessfi.xyz'; + +interface OrgProfilePageProps { + params: Promise<{ slug: string }>; +} + +export async function generateMetadata({ + params, +}: OrgProfilePageProps): Promise { + try { + const { slug } = await params; + const org = await getOrganizationProfile(slug); + + const title = `${org.name} | Boundless`; + const description = org.description || `View ${org.name} on Boundless`; + const ogImageUrl = `${SITE_URL}/api/og?slug=${encodeURIComponent(slug)}`; + + return { + title, + description, + openGraph: { + title, + description, + url: `${SITE_URL}/org/${slug}`, + siteName: 'Boundless', + images: [ + { + url: ogImageUrl, + width: 1200, + height: 630, + alt: org.name, + type: 'image/png', + }, + ], + }, + twitter: { + card: 'summary_large_image', + title, + description, + images: [ogImageUrl], + }, + }; + } catch { + return { + title: 'Organization | Boundless', + description: 'View organization profile on Boundless.', + openGraph: { + images: [`${SITE_URL}/api/og`], + }, + }; + } +} + +export default async function OrgProfilePage({ params }: OrgProfilePageProps) { + const { slug } = await params; + + return ; +} diff --git a/app/(landing)/organizations/[id]/grants/page.tsx b/app/(landing)/organizations/[id]/grants/page.tsx index a3484d8a2..d6b0ae948 100644 --- a/app/(landing)/organizations/[id]/grants/page.tsx +++ b/app/(landing)/organizations/[id]/grants/page.tsx @@ -1,7 +1,9 @@ +import { redirect } from 'next/navigation'; import { AuthGuard } from '@/components/auth'; import Loading from '@/components/Loading'; export default function GrantsPage() { + redirect('/coming-soon'); return ( }>
diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/announcement/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/announcement/page.tsx index df479615a..d73e70426 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/announcement/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/announcement/page.tsx @@ -1,84 +1,376 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useParams } from 'next/navigation'; -import { Megaphone } from 'lucide-react'; +import { Megaphone, Trash2, Edit2, Pin, Send } from 'lucide-react'; import { AnnouncementEditor } from '@/components/ui/shadcn-io/announcement-editor'; import { BoundlessButton } from '@/components/buttons'; import { toast } from 'sonner'; -import { api } from '@/lib/api/api'; import { AuthGuard } from '@/components/auth'; import Loading from '@/components/Loading'; +import { + createAnnouncement, + listAnnouncements, + deleteAnnouncement, + publishAnnouncement, + updateAnnouncement, + HackathonAnnouncement, +} from '@/lib/api/hackathons/index'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Switch } from '@/components/ui/switch'; export default function AnnouncementPage() { const params = useParams(); const organizationId = params.id as string; const hackathonId = params.hackathonId as string; + const [announcements, setAnnouncements] = useState( + [] + ); + const [isLoading, setIsLoading] = useState(true); + const [title, setTitle] = useState(''); const [content, setContent] = useState(''); - const [isPublishing, setIsPublishing] = useState(false); + const [isPinned, setIsPinned] = useState(false); + const [isDraft, setIsDraft] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [editingId, setEditingId] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [announcementToDelete, setAnnouncementToDelete] = useState< + string | null + >(null); + + useEffect(() => { + fetchAnnouncements(); + }, [hackathonId]); + + const fetchAnnouncements = async () => { + try { + const data = await listAnnouncements(hackathonId); + setAnnouncements(data); + } catch (error) { + console.error('Failed to fetch announcements:', error); + toast.error('Failed to load announcements'); + } finally { + setIsLoading(false); + } + }; - const handlePublish = async () => { + const handleSave = async (draft = true) => { + if (!title.trim()) { + toast.error('Please enter a title'); + return; + } if (!content.trim()) { toast.error('Please enter announcement content'); return; } - setIsPublishing(true); + setIsSubmitting(true); try { - await api.post( - `/organizations/${organizationId}/hackathons/${hackathonId}/announcements`, - { + if (editingId) { + await updateAnnouncement(organizationId, hackathonId, editingId, { + title, + content, + isPinned, + isDraft: draft, + }); + toast.success( + draft ? 'Announcement saved as draft' : 'Announcement updated' + ); + } else { + await createAnnouncement(organizationId, hackathonId, { + title, content, - } + isPinned, + isDraft: draft, + }); + toast.success( + draft ? 'Announcement saved as draft' : 'Announcement published' + ); + } + resetForm(); + fetchAnnouncements(); + } catch (error) { + console.error('Failed to save announcement:', error); + toast.error('Failed to save announcement'); + } finally { + setIsSubmitting(false); + } + }; + + const handleDelete = async () => { + if (!announcementToDelete) return; + + try { + await deleteAnnouncement( + organizationId, + hackathonId, + announcementToDelete ); + toast.success('Announcement deleted'); + fetchAnnouncements(); + } catch (error) { + console.error('Failed to delete announcement:', error); + toast.error('Failed to delete announcement'); + } finally { + setIsDeleteDialogOpen(false); + setAnnouncementToDelete(null); + } + }; - toast.success('Announcement published successfully!'); - setContent(''); - } catch { + const handlePublishDraft = async (id: string) => { + try { + await publishAnnouncement(organizationId, hackathonId, id); + toast.success('Announcement published'); + fetchAnnouncements(); + } catch (error) { + console.error('Failed to publish announcement:', error); toast.error('Failed to publish announcement'); - } finally { - setIsPublishing(false); } }; + const handleEdit = (announcement: HackathonAnnouncement) => { + setEditingId(announcement.id); + setTitle(announcement.title); + setContent(announcement.content); + setIsPinned(announcement.isPinned); + setIsDraft(announcement.isDraft); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const resetForm = () => { + setEditingId(null); + setTitle(''); + setContent(''); + setIsPinned(false); + setIsDraft(true); + }; + return ( }> -
-
-
-

- Create Announcement -

-

- Share important updates, reminders, or announcements with the - community. -

+
+
+ {/* Header */} +
+
+

+ {editingId ? 'Edit Announcement' : 'Create Announcement'} +

+

+ {editingId + ? 'Update your announcement details.' + : 'Share important updates, reminders, or announcements with the community.'} +

+
+ {editingId && ( + + Cancel Edit + + )} +
+ + {/* Editor Card */} +
+
+
+ + setTitle(e.target.value)} + placeholder='Enter announcement title' + className='focus:border-primary/50 border-zinc-800 bg-zinc-950/50 text-white' + maxLength={100} + /> +
+ +
+ + +
+ +
+
+
+ + +
+ +
+
+
+ +
+ handleSave(false)} + disabled={isSubmitting} + className='bg-primary hover:bg-primary/90' + > + + {editingId ? 'Update & Publish' : 'Publish Now'} + + handleSave(true)} + disabled={isSubmitting} + className='border-zinc-800 text-zinc-300' + > + {editingId ? 'Save Draft' : 'Save as Draft'} + +
-
- + {/* List of Announcements */} +
+
+ +

Existing Announcements

+
+ + {isLoading ? ( +
+ +
+ ) : announcements.length === 0 ? ( +
+ No announcements yet. +
+ ) : ( +
+ {announcements.map(item => ( +
+ +
+ {item.isPinned && ( + + )} +

+ {item.title} +

+ {item.isDraft && ( + + Draft + + )} +
+

+ {item.content.replace(/<[^>]*>/g, '')} +

+
+ + {new Date(item.createdAt).toLocaleDateString()} + + By {item.author?.name || 'Unknown'} +
+
+ +
+ {item.isDraft && ( + + )} + + +
+
+ ))} +
+ )}
+
+
-
+ {/* Delete Confirmation Dialog */} + + + + Delete Announcement + +

+ Are you sure you want to delete this announcement? This action + cannot be undone. +

+ + setIsDeleteDialogOpen(false)} + className='border-zinc-800' + > + Cancel + - - {isPublishing ? 'Publishing...' : 'Publish Announcement'} + Delete -
-
-
+ + + ); } diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx index 5643b1dd6..ceb280bbc 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx @@ -3,152 +3,735 @@ import { useEffect, useState, useCallback } from 'react'; import MetricsCard from '@/components/organization/cards/MetricsCard'; import JudgingParticipant from '@/components/organization/cards/JudgingParticipant'; +import EmptyState from '@/components/EmptyState'; import { useParams } from 'next/navigation'; import { getJudgingSubmissions, + getJudgingCriteria, + addJudge, + removeJudge, + getHackathonJudges, + getJudgingResults, + getJudgingWinners, + publishJudgingResults, + type JudgingCriterion, type JudgingSubmission, -} from '@/lib/api/hackathons'; -import { Loader2 } from 'lucide-react'; + type JudgingResult, + type AggregatedJudgingResults, +} from '@/lib/api/hackathons/judging'; +import { getSubmissionDetails } from '@/lib/api/hackathons/participants'; +import { getOrganizationMembers } from '@/lib/api/organization'; +import { getCrowdfundingProject } from '@/features/projects/api'; +import { authClient } from '@/lib/auth-client'; +import { useOrganization } from '@/lib/providers/OrganizationProvider'; +import { Loader2, Trophy, CheckCircle2 } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { AuthGuard } from '@/components/auth/AuthGuard'; import Loading from '@/components/Loading'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { JudgingCriteriaList } from '@/components/organization/hackathons/judging/JudgingCriteriaList'; +import JudgingResultsTable from '@/components/organization/hackathons/judging/JudgingResultsTable'; export default function JudgingPage() { const params = useParams(); const organizationId = params.id as string; const hackathonId = params.hackathonId as string; + const { activeOrgId, activeOrg } = useOrganization(); const [submissions, setSubmissions] = useState([]); + const [criteria, setCriteria] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [page, setPage] = useState(1); - const [pagination, setPagination] = useState({ - page: 1, - totalPages: 1, - total: 0, - limit: 10, - }); - - const fetchSubmissions = useCallback( - async (pageNum = 1) => { - if (!organizationId || !hackathonId) return; - - setIsLoading(true); + const [activeTab, setActiveTab] = useState('overview'); + const [isAddingJudge, setIsAddingJudge] = useState(false); + const [orgMembers, setOrgMembers] = useState([]); + const [currentJudges, setCurrentJudges] = useState([]); + const [isRefreshingJudges, setIsRefreshingJudges] = useState(false); + const [currentUserRole, setCurrentUserRole] = useState< + 'owner' | 'admin' | 'member' | null + >(null); + const [judgingResults, setJudgingResults] = useState([]); + const [judgingSummary, setJudgingSummary] = + useState(null); + const [isFetchingResults, setIsFetchingResults] = useState(false); + const [winners, setWinners] = useState([]); + const [isFetchingWinners, setIsFetchingWinners] = useState(false); + const [isPublishing, setIsPublishing] = useState(false); + const [isCurrentUserJudge, setIsCurrentUserJudge] = useState(false); + const [currentUserId, setCurrentUserId] = useState(null); + + const canManageJudges = + currentUserRole === 'owner' || currentUserRole === 'admin'; + const canPublishResults = canManageJudges && isCurrentUserJudge; + const resultsPublished = winners.length > 0; + + const fetchJudges = useCallback(async () => { + // Priority: activeOrgId from context, then params.id + const targetOrgId = activeOrgId || organizationId; + if (!targetOrgId || !hackathonId) return; + + setIsRefreshingJudges(true); + let finalMembers: any[] = []; + let judges: any[] = []; + + // 1. Try to fetch organization members + try { + // First try legacy API try { - const response = await getJudgingSubmissions( - organizationId, - hackathonId, - pageNum, - 10 + const membersRes = await getOrganizationMembers(targetOrgId); + if ( + membersRes.success && + Array.isArray(membersRes.data) && + membersRes.data.length > 0 + ) { + finalMembers = membersRes.data; + } + } catch (err) { + console.warn( + 'Legacy member fetch failed, trying Better Auth fallback:', + err ); + } - if (response.success) { - setSubmissions(response.data || []); - setPagination(response.meta.pagination); - setPage(pageNum); + // If still empty, try Better Auth directly + if (finalMembers.length === 0) { + const { data: baData } = await authClient.organization.listMembers({ + query: { organizationId: targetOrgId, limit: 100 }, + }); + + if (baData?.members && Array.isArray(baData.members)) { + finalMembers = baData.members.map((m: any) => ({ + id: m.userId, + userId: m.userId, + name: m.user.name || m.user.email, + email: m.user.email, + image: m.user.image, + role: m.role, + })); } - } catch { - toast.error('Failed to load submissions'); - } finally { - setIsLoading(false); } - }, - [organizationId, hackathonId] - ); + } catch (err) { + console.error('All member fetching attempts failed:', err); + } + + // 2. Fetch judges + try { + const judgesRes = await getHackathonJudges(targetOrgId, hackathonId); + if (judgesRes.success) { + judges = judgesRes.data || []; + } + } catch (err) { + console.error('Failed to fetch judges:', err); + } + + setOrgMembers(finalMembers); + setCurrentJudges(judges); + setIsRefreshingJudges(false); + + // Determine current user role and judge status + const { data: session } = await authClient.getSession(); + const currentUserId = session?.user?.id; + if (currentUserId && finalMembers.length > 0) { + const me = finalMembers.find( + (m: any) => m.userId === currentUserId || m.id === currentUserId + ); + setCurrentUserRole(me?.role || null); + + // Check if current user is a judge + const isJudge = judges.some( + (j: any) => j.userId === currentUserId || j.id === currentUserId + ); + setIsCurrentUserJudge(isJudge); + } + + // Set current user ID for child components + if (currentUserId) { + setCurrentUserId(currentUserId); + } + }, [organizationId, hackathonId, activeOrgId]); + + const fetchResults = useCallback(async () => { + if (!organizationId || !hackathonId) return; + + setIsFetchingResults(true); + try { + const res = await getJudgingResults(organizationId, hackathonId); + + if (res.success && res.data) { + setJudgingResults(res.data.results || []); + setJudgingSummary(res.data); + } else { + setJudgingResults([]); + setJudgingSummary(null); + if (!res.success) { + toast.error((res as any).message || 'Failed to load judging results'); + } + } + } catch (error: any) { + console.error('Error fetching results:', error); + setJudgingResults([]); + setJudgingSummary(null); + toast.error( + error.response?.data?.message || + error.message || + 'Failed to load judging results' + ); + } finally { + setIsFetchingResults(false); + } + }, [organizationId, hackathonId]); + + const fetchWinners = useCallback(async () => { + if (!organizationId || !hackathonId) return; + setIsFetchingWinners(true); + try { + const res = await getJudgingWinners(organizationId, hackathonId); + if (res.success && res.data) { + setWinners(Array.isArray(res.data) ? res.data : []); + } + } catch (error) { + console.error('Error fetching winners:', error); + } finally { + setIsFetchingWinners(false); + } + }, [organizationId, hackathonId]); + + const fetchData = useCallback(async () => { + if (!organizationId || !hackathonId) return; + + setIsLoading(true); + try { + // Fetch submissions, criteria, and judges/members + const [submissionsRes, criteriaRes] = await Promise.all([ + getJudgingSubmissions(organizationId, hackathonId, 1, 50), + getJudgingCriteria(hackathonId), + ]); + + // Trigger judges, results, and winners fetch in parallel (winners => results published state) + fetchJudges(); + fetchResults(); + fetchWinners(); + + let enrichedSubmissions: JudgingSubmission[] = []; + + if (submissionsRes.success) { + // Standard submissions endpoint returns { data: { submissions: [], pagination: {} } } + const submissionData = + (submissionsRes.data as any)?.submissions || + submissionsRes.data || + []; + const basicSubmissions = Array.isArray(submissionData) + ? submissionData + : []; + + // 2. Fetch full details for each submission to get user info + // We do this by fetching the project details, as submission endpoints lack user data + const detailsPromises = basicSubmissions.map(async (sub: any) => { + try { + // Check if we already have sufficient user data + if ( + sub.participant?.user?.profile?.firstName || + sub.participant?.name + ) + return sub; + + // Try fetch project details if we have projectId + if (sub.projectId) { + const project = await getCrowdfundingProject(sub.projectId); + if (project && project.project && project.project.creator) { + const creator = project.project.creator; + return { + ...sub, + participant: { + ...sub.participant, + // Use creator info for participant + name: creator.name, + username: creator.username, + image: creator.image, + email: creator.email, + user: { + ...sub.participant?.user, + name: creator.name, + username: creator.username, + image: creator.image, + email: creator.email, + profile: { + ...sub.participant?.user?.profile, + firstName: creator.name?.split(' ')[0] || '', + lastName: + creator.name?.split(' ').slice(1).join(' ') || '', + username: creator.username, + avatar: creator.image, + }, + }, + }, + }; + } + } + + // Fallback to submission details check if project fail or no projectId + const detailsRes = await getSubmissionDetails(sub.id); + if (detailsRes.success && detailsRes.data) { + const details = detailsRes.data as any; + return { + ...sub, + participant: { + ...sub.participant, + ...details.participant, + user: details.participant?.user || sub.participant?.user, + }, + }; + } + return sub; + } catch (err) { + console.error( + `Failed to fetch details for submission ${sub.id}`, + err + ); + return sub; + } + }); + + enrichedSubmissions = await Promise.all(detailsPromises); + setSubmissions(enrichedSubmissions); + } else { + setSubmissions([]); + } + + // Handle criteria response safely + setCriteria(Array.isArray(criteriaRes) ? criteriaRes : []); + } catch (error) { + console.error('Judging data fetch error:', error); + toast.error('Failed to load judging data'); + } finally { + setIsLoading(false); + } + }, [organizationId, hackathonId, fetchJudges, fetchResults, fetchWinners]); useEffect(() => { - fetchSubmissions(); - }, [fetchSubmissions]); + fetchData(); + }, [fetchData]); const handleSuccess = () => { - fetchSubmissions(page); + fetchData(); + fetchResults(); // Refresh results to update metrics/table + }; + + const handleAddJudge = async (userId: string, email: string) => { + setIsAddingJudge(true); + try { + const res = await addJudge(organizationId, hackathonId, { + userId, + email, + }); + if (res.success) { + toast.success('Judge assigned successfully'); + fetchJudges(); + } else { + toast.error(res.message || 'Failed to assign judge'); + } + } catch (error: any) { + console.error('Error adding judge:', error); + toast.error( + error.response?.data?.message || + error.message || + 'Failed to assign judge' + ); + } finally { + setIsAddingJudge(false); + } }; - // Calculate statistics - const totalSubmissions = pagination.total; - const averageScore = - submissions.length > 0 - ? submissions.reduce((sum, sub) => { - return sum + (sub.averageScore || 0); - }, 0) / submissions.length + const handleRemoveJudge = async (userId: string) => { + try { + const res = await removeJudge(organizationId, hackathonId, userId); + if (res.success) { + toast.success('Judge removed successfully'); + fetchJudges(); + } else { + toast.error(res.message || 'Failed to remove judge'); + } + } catch (error: any) { + console.error('Error removing judge:', error); + toast.error( + error.response?.data?.message || + error.message || + 'Failed to remove judge' + ); + } + }; + + const handlePublishResults = async () => { + setIsPublishing(true); + try { + const res = await publishJudgingResults(organizationId, hackathonId); + if (res.success) { + toast.success('Results published successfully!'); + fetchResults(); + fetchWinners(); + } else { + toast.error(res.message || 'Failed to publish results'); + } + } catch (error: any) { + toast.error(error.message || 'Failed to publish results'); + } finally { + setIsPublishing(false); + } + }; + + // Use pre-calculated statistics from the API if available, otherwise fallback to local calculation + const gradedCount = judgingSummary + ? judgingSummary.submissionsScoredCount + : judgingResults.length; + + const totalPossibleSubmissions = judgingSummary + ? judgingSummary.totalSubmissions + : submissions.length; + + const averageHackathonScore = judgingSummary + ? judgingSummary.averageScoreAcrossAll + : judgingResults.length > 0 + ? judgingResults.reduce( + (acc, curr) => acc + (curr.averageScore || 0), + 0 + ) / judgingResults.length : 0; - const totalJudges = new Set( - submissions.flatMap(sub => sub.scores.map(score => score.judge.id)) - ).size; + + const assignedJudgesCount = judgingSummary + ? judgingSummary.judgesAssigned + : currentJudges.length; return ( }> -
-
- - 0 ? averageScore.toFixed(2) : 'N/A'} - subtitle={ - submissions.length > 0 ? 'Across all submissions' : undefined - } - /> - -
- - {isLoading ? ( -
- +
+
+
+

Judging Dashboard

+

+ Manage and grade shortlisted submissions +

- ) : submissions.length === 0 ? ( -
- No shortlisted submissions found + +
+ 0 ? Math.round((gradedCount / totalPossibleSubmissions) * 100) : 0}% Completion`} + /> + +
- ) : ( - <> -
- {submissions.map(submission => ( - { + setActiveTab(value); + if (value === 'results') { + fetchResults(); + fetchWinners(); + } + }} + className='w-full' + > + + + Overview + + + Criteria + + + Judges + + + Results + + + + + {isLoading ? ( +
+ +
+ ) : submissions.length > 0 ? ( +
+ {submissions.map(submission => ( + 0} + judges={currentJudges} + isJudgesLoading={isRefreshingJudges} + currentUserId={currentUserId || undefined} + canOverrideScores={canManageJudges} + onSuccess={handleSuccess} + /> + ))} +
+ ) : ( + - ))} -
- - {/* Pagination */} - {pagination.totalPages > 1 && ( -
- - - Page {pagination.page} of {pagination.totalPages} - - + )} + + + + + + + +
+ {/* Current Judges List */} +
+

+ Current Judges + {isRefreshingJudges && ( + + )} +

+
+ {currentJudges.length === 0 ? ( + + ) : ( + currentJudges.map((judge: any, index: number) => ( +
+
+
+ {judge.image ? ( + + ) : ( +
+ {judge.name?.[0] || '?'} +
+ )} +
+
+

+ {judge.name} +

+

+ Judge {index + 1} +

+
+
+ {canManageJudges && ( + + )} +
+ )) + )} +
+
+ + {/* Org Members List - Only visible to admin/owner */} + {canManageJudges && ( +
+

+ Add from Organization Members +

+

+ Select members from your organization to assign them as + judges. +

+
+ {orgMembers.map((member: any) => { + const isAlreadyJudge = currentJudges.some( + j => j.id === member.id || j.userId === member.id + ); + return ( +
+
+
+ {member.image ? ( + + ) : ( +
+ {member.name?.[0] || + member.username?.[0] || + '?'} +
+ )} +
+
+

+ {member.name || member.username} +

+

+ {member.email} +

+
+
+ +
+ ); + })} + {orgMembers.length === 0 && !isRefreshingJudges && ( + + )} +
+
+ )}
- )} - - )} +
+ + +
+ {resultsPublished && ( +
+ +
+

+ Results published +

+

+ Winner rankings are live. This hackathon's results + have been finalized. +

+
+
+ )} + + {!resultsPublished && + canPublishResults && + judgingResults.length > 0 && ( +
+
+

+ Finalize Competition +

+

+ Publish the current rankings to name the winners. +

+
+ +
+ )} + + {winners.length > 0 && ( +
+

+ + Final Winners +

+ +
+ )} + +
+

+ Current Standings +

+ {isFetchingResults ? ( +
+ +
+ ) : judgingResults.length > 0 ? ( + + ) : ( + + )} +
+
+
+ +
); diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/page.tsx index cf5323177..884c6275f 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/page.tsx @@ -9,7 +9,7 @@ import { Check, } from 'lucide-react'; import { useHackathons } from '@/hooks/use-hackathons'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useHackathonAnalytics } from '@/hooks/use-hackathon-analytics'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { HackathonStatistics } from '@/components/organization/hackathons/details/HackathonStatistics'; @@ -17,12 +17,21 @@ import { HackathonCharts } from '@/components/organization/hackathons/details/Ha import { HackathonTimeline } from '@/components/organization/hackathons/details/HackathonTimeline'; import { AuthGuard } from '@/components/auth'; import Loading from '@/components/Loading'; +import HackathonPublishedModal from '@/components/organization/hackathons/new/tabs/components/review/HackathonPublishedModal'; +import type { PublishResponseData } from '@/hooks/use-hackathon-publish'; + +const STORAGE_KEY = 'boundless_hackathon_published'; export default function HackathonPage() { const params = useParams(); const organizationId = params.id as string; const hackathonId = params.hackathonId as string; + const [publishedModalData, setPublishedModalData] = useState<{ + publishResponse: PublishResponseData; + organizationId: string; + } | null>(null); + const { currentHackathon, currentLoading, currentError, fetchHackathon } = useHackathons({ organizationId, @@ -41,6 +50,37 @@ export default function HackathonPage() { } }, [organizationId, hackathonId, fetchHackathon]); + useEffect(() => { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return; + const payload = JSON.parse(raw) as { + organizationId: string; + id: string; + slug: string; + publishedAt: string; + message: string; + escrowAddress: string; + transactionHash: string | null; + }; + if (payload.id !== hackathonId) return; + sessionStorage.removeItem(STORAGE_KEY); + setPublishedModalData({ + publishResponse: { + id: payload.id, + slug: payload.slug, + publishedAt: payload.publishedAt, + message: payload.message, + escrowAddress: payload.escrowAddress, + transactionHash: payload.transactionHash, + }, + organizationId: payload.organizationId, + }); + } catch { + // ignore + } + }, [hackathonId]); + if (currentLoading) { return (
@@ -95,6 +135,15 @@ export default function HackathonPage() { return ( }>
+ { + if (!open) setPublishedModalData(null); + }} + publishResponse={publishedModalData?.publishResponse ?? null} + organizationId={publishedModalData?.organizationId} + /> + {/* Hero Section with Hackathon Name */}
@@ -158,31 +207,7 @@ export default function HackathonPage() { e => e.phase === 'Winner Announcement' ); - // Manually append Winner Announcement if missing and date exists const fullTimeline = [...timelineEvents]; - if (!hasWinnerAnnouncement && currentHackathon?.endDate) { - const winnerDate = new Date(currentHackathon.endDate); - const now = new Date(); - // Simple status logic for single date event - // If date is passed, completed. If today (roughly), ongoing? - // Or just use 'upcoming' if future, 'completed' if past. - // Ideally we'd match the phase logic, but for a single date event: - let status: 'completed' | 'ongoing' | 'upcoming' = - 'upcoming'; - if (now > winnerDate) { - status = 'completed'; - } - // For "Winner Announcement", it might be "ongoing" on the day of? - // keeping simple for now. - - fullTimeline.push({ - phase: 'Winner Announcement', - description: - 'Final results published and prizes distributed to winners.', - date: currentHackathon.endDate, - status: status, - }); - } return fullTimeline.map((phase, index) => { const isLast = index === fullTimeline.length - 1; @@ -229,7 +254,7 @@ export default function HackathonPage() { {phase.description}

-
+
{new Date(phase.date).toLocaleDateString()}
diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx index 712d31c25..77c4ee389 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx @@ -137,9 +137,13 @@ const ParticipantsPage: React.FC = () => { organizationId, actualHackathonId ); + const stats = response.data as { + totalSubmissions?: number; + activeParticipants?: number; + }; setStatistics({ - participantsCount: response.data.participantsCount, - submissionsCount: response.data.submissionsCount, + participantsCount: stats.activeParticipants ?? 0, + submissionsCount: stats.totalSubmissions ?? 0, }); } catch (err) { console.error('Failed to load statistics', err); diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/rewards/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/rewards/page.tsx index ca45e6361..e97849a2c 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/rewards/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/rewards/page.tsx @@ -7,6 +7,7 @@ import PublishWinnersWizard from '@/components/organization/hackathons/rewards/P import { RewardsPageHeader } from '@/components/organization/hackathons/rewards/RewardsPageHeader'; import { RewardsPageContent } from '@/components/organization/hackathons/rewards/RewardsPageContent'; import { useHackathonRewards } from '@/hooks/use-hackathon-rewards'; +import { useRewardDistributionStatus } from '@/hooks/use-reward-distribution-status'; import { useRankAssignment } from '@/hooks/use-rank-assignment'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { AuthGuard } from '@/components/auth'; @@ -27,11 +28,21 @@ export default function RewardsPage() { isLoadingSubmissions, error, refreshEscrow, + refetchHackathon, + resultsPublished, + hackathon, } = useHackathonRewards(organizationId, hackathonId); const { handleRankChange } = useRankAssignment(); const [isPublishWizardOpen, setIsPublishWizardOpen] = useState(false); + const { + distributionStatus, + isLoading: isLoadingDistributionStatus, + error: distributionError, + refetch: refetchDistributionStatus, + } = useRewardDistributionStatus(organizationId, hackathonId); + const maxRank = useMemo(() => prizeTiers.length, [prizeTiers.length]); const winners = useMemo( () => submissions.filter(s => s.rank && s.rank <= maxRank), @@ -56,6 +67,8 @@ export default function RewardsPage() { const handlePublishSuccess = () => { refreshEscrow(); + refetchDistributionStatus(); + refetchHackathon(); }; return ( @@ -85,6 +98,14 @@ export default function RewardsPage() { )} + {!isLoading && distributionError && ( + + + Distribution Status Error + {distributionError} + + )} + {!isLoading && !error && ( setIsPublishWizardOpen(true)} onRankChange={handleRankChangeWrapper} + distributionStatus={distributionStatus} + isLoadingDistributionStatus={isLoadingDistributionStatus} + onRefreshDistributionStatus={refetchDistributionStatus} + resultsPublished={resultsPublished} + escrowAddress={hackathon?.escrowAddress || hackathon?.contractId} /> )} diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/settings/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/settings/page.tsx index 2ba98388a..057d07dff 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/settings/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/settings/page.tsx @@ -16,6 +16,8 @@ import { } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/lib/api/api'; +import { getHackathon, Hackathon } from '@/lib/api/hackathons'; +import { useEffect } from 'react'; import GeneralSettingsTab from '@/components/organization/hackathons/settings/GeneralSettingsTab'; import TimelineSettingsTab from '@/components/organization/hackathons/settings/TimelineSettingsTab'; import ParticipantSettingsTab from '@/components/organization/hackathons/settings/ParticipantSettingsTab'; @@ -32,101 +34,152 @@ export default function SettingsPage() { const hackathonId = params.hackathonId as string; const [isSaving, setIsSaving] = useState(false); + const [hackathon, setHackathon] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const fetchHackathon = async () => { + try { + const res = await getHackathon(hackathonId); + setHackathon(res.data); + } catch { + toast.error('Failed to load hackathon data'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (hackathonId) { + fetchHackathon(); + } + }, [hackathonId]); const tabTriggerClassName = 'data-[state=active]:border-b-primary rounded-none border-b-2 border-transparent bg-transparent px-0 pt-4 pb-3 text-sm font-medium text-gray-400 transition-all data-[state=active]:text-white data-[state=active]:shadow-none flex items-center gap-2'; - const mockHackathonData = { - info: { - name: 'Web3 Innovation Hackathon', - banner: 'https://example.com/banner.jpg', - description: '

Join us for an exciting hackathon...

', - category: ['DeFi' as const], - venueType: 'virtual' as const, - country: '', - state: '', - city: '', - venueName: '', - venueAddress: '', - }, - timeline: { - startDate: new Date('2025-01-15'), - endDate: new Date('2025-01-20'), - registrationDeadline: new Date('2025-01-10'), - submissionDeadline: new Date('2025-01-18'), - timezone: 'UTC', - phases: [], - }, - participant: { - participantType: 'team_or_individual' as const, - teamMin: 2, - teamMax: 5, - about: '', - require_github: true, - require_demo_video: true, - require_other_links: false, - details_tab: true, - schedule_tab: true, - rules_tab: true, - reward_tab: true, - announcements_tab: true, - partners_tab: true, - join_a_team_tab: true, - projects_tab: true, - participants_tab: true, - }, - rewards: { - prizeTiers: [ - { - id: '1', - place: '1st', - prizeAmount: '10000', - currency: 'USDC', - description: '', - passMark: 80, - }, - { - id: '2', - place: '2nd', - prizeAmount: '5000', - currency: 'USDC', - description: '', - passMark: 70, - }, - { - id: '3', - place: '3rd', - prizeAmount: '3000', - currency: 'USDC', - description: '', - passMark: 60, - }, - ], - }, - collaboration: { - contactEmail: 'contact@example.com', - telegram: '', - discord: '', - socialLinks: [], - sponsorsPartners: [], - }, + // Mapping functions to convert Hackathon to tab data types + const getGeneralData = (h: Hackathon | null) => { + if (!h) return undefined; + return { + name: h.name, + tagline: h.tagline, + slug: h.slug, + banner: h.banner, + description: h.description, + categories: h.categories, + venueType: h.venueType.toLowerCase() as any, + country: h.country, + state: h.state, + city: h.city, + venueName: h.venueName, + venueAddress: h.venueAddress, + }; + }; + + const getTimelineData = (h: Hackathon | null) => { + if (!h) return undefined; + return { + startDate: h.startDate ? new Date(h.startDate) : undefined, + submissionDeadline: h.submissionDeadline + ? new Date(h.submissionDeadline) + : undefined, + timezone: h.timezone || 'UTC', + registrationDeadline: h.registrationDeadline + ? new Date(h.registrationDeadline) + : undefined, + judgingDeadline: h.judgingDeadline + ? new Date(h.judgingDeadline) + : undefined, + phases: h.phases?.map(p => ({ + ...p, + startDate: p.startDate ? new Date(p.startDate) : undefined, + endDate: p.endDate ? new Date(p.endDate) : undefined, + })) as any, + }; + }; + + const getParticipantData = (h: Hackathon | null) => { + if (!h) return undefined; + return { + participantType: h.participantType.toLowerCase() as any, + teamMin: h.teamMin, + teamMax: h.teamMax, + require_github: h.requireGithub, + require_demo_video: h.requireDemoVideo, + require_other_links: h.requireOtherLinks, + detailsTab: h.enabledTabs.includes('detailsTab'), + participantsTab: h.enabledTabs.includes('participantsTab'), + resourcesTab: h.enabledTabs.includes('resourcesTab'), + submissionTab: h.enabledTabs.includes('submissionTab'), + announcementsTab: h.enabledTabs.includes('announcementsTab'), + discussionTab: h.enabledTabs.includes('discussionTab'), + winnersTab: h.enabledTabs.includes('winnersTab'), + sponsorsTab: h.enabledTabs.includes('sponsorsTab'), + joinATeamTab: h.enabledTabs.includes('joinATeamTab'), + rulesTab: h.enabledTabs.includes('rulesTab'), + }; + }; + + const getAdvancedData = (h: Hackathon | null) => { + if (!h) return undefined; + const adv = h.metadata?.advancedSettings; + return { + isPublic: adv?.isPublic ?? true, + allowLateRegistration: adv?.allowLateRegistration ?? false, + requireApproval: adv?.requireApproval ?? false, + maxParticipants: adv?.maxParticipants, + customDomain: adv?.customDomain || '', + enableDiscord: adv?.enableDiscord ?? !!h.discord, + discordInviteLink: adv?.discordInviteLink || h.discord || '', + enableTelegram: adv?.enableTelegram ?? !!h.telegram, + telegramInviteLink: adv?.telegramInviteLink || h.telegram || '', + }; }; const handleSave = async (section: string, data: unknown) => { setIsSaving(true); try { - await api.patch( - `/organizations/${organizationId}/hackathons/${hackathonId}/settings/${section.toLowerCase()}`, - data - ); + if (section === 'General') { + await api.patch( + `/organizations/${organizationId}/hackathons/${hackathonId}/content`, + { information: data } + ); + } else if (section === 'Collaboration') { + await api.patch( + `/organizations/${organizationId}/hackathons/${hackathonId}/content`, + { collaboration: data } + ); + } else if (section === 'Participants') { + await api.patch( + `/organizations/${organizationId}/hackathons/${hackathonId}/schedule`, + { participation: data } + ); + } else if (section === 'Rewards') { + await api.patch( + `/organizations/${organizationId}/hackathons/${hackathonId}/financial`, + { rewards: data } + ); + } else { + await api.patch( + `/organizations/${organizationId}/hackathons/${hackathonId}/settings/${section.toLowerCase()}`, + data + ); + } toast.success(`${section} settings saved successfully!`); - } catch { - toast.error(`Failed to save ${section} settings`); + await fetchHackathon(); // Call fetchHackathon after successful save + } catch (error: any) { + const message = + error.response?.data?.message || `Failed to save ${section} settings`; + const errorMessage = Array.isArray(message) ? message[0] : message; + toast.error(errorMessage); + throw error; // Re-throw to let callers know it failed } finally { setIsSaving(false); } }; + if (isLoading) return ; + return ( }>
@@ -208,9 +261,17 @@ export default function SettingsPage() { handleSave('General', data)} + initialData={getGeneralData(hackathon) as any} + onSave={async data => { + await handleSave('General', data); + }} isLoading={isSaving} + isPublished={[ + 'UPCOMING', + 'ACTIVE', + 'JUDGING', + 'COMPLETED', + ].includes(hackathon?.status ?? '')} /> @@ -218,9 +279,8 @@ export default function SettingsPage() { handleSave('Timeline', data)} - isLoading={isSaving} + initialData={getTimelineData(hackathon)} + onSaveSuccess={fetchHackathon} /> @@ -228,8 +288,15 @@ export default function SettingsPage() { handleSave('Participants', data)} + initialData={ + hackathon ? getParticipantData(hackathon) : undefined + } + isRegistrationClosed={ + hackathon ? !hackathon.registrationOpen : false + } + onSave={async data => { + await handleSave('Participants', data); + }} isLoading={isSaving} /> @@ -238,8 +305,10 @@ export default function SettingsPage() { handleSave('Rewards', data)} + initialData={{ prizeTiers: hackathon?.prizeTiers || [] } as any} + onSave={async data => { + await handleSave('Rewards', data); + }} isLoading={isSaving} /> @@ -248,8 +317,18 @@ export default function SettingsPage() { handleSave('Collaboration', data)} + initialData={ + { + contactEmail: hackathon?.contactEmail || '', + telegram: hackathon?.telegram || '', + discord: hackathon?.discord || '', + socialLinks: hackathon?.socialLinks || [], + sponsorsPartners: hackathon?.sponsorsPartners || [], + } as any + } + onSave={async data => { + await handleSave('Collaboration', data); + }} isLoading={isSaving} /> @@ -258,8 +337,8 @@ export default function SettingsPage() { handleSave('Advanced', data)} - isLoading={isSaving} + initialData={getAdvancedData(hackathon)} + onSaveSuccess={fetchHackathon} /> diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/submissions/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/submissions/page.tsx index 7e02bb40d..46f22f9d5 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/submissions/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/submissions/page.tsx @@ -1,13 +1,15 @@ 'use client'; import { useParams } from 'next/navigation'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Loader2, AlertCircle } from 'lucide-react'; -import { useHackathonSubmissions } from '@/hooks/hackathon/use-hackathon-submissions'; +import { useOrganizerSubmissions } from '@/hooks/hackathon/use-organizer-submissions'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { AuthGuard } from '@/components/auth'; import Loading from '@/components/Loading'; import { SubmissionsManagement } from '@/components/organization/hackathons/submissions/SubmissionsManagement'; +import { authClient } from '@/lib/auth-client'; +import { getHackathon, type Hackathon } from '@/lib/api/hackathons'; export default function SubmissionsPage() { const params = useParams(); @@ -24,14 +26,42 @@ export default function SubmissionsPage() { updateFilters, goToPage, refresh, - } = useHackathonSubmissions(hackathonId); + } = useOrganizerSubmissions(hackathonId); + + const [currentUserId, setCurrentUserId] = useState(null); + const [hackathon, setHackathon] = useState(null); useEffect(() => { if (hackathonId) { fetchSubmissions(); + const fetchHackathonDetails = async () => { + try { + const res = await getHackathon(hackathonId); + if (res.success && res.data) { + setHackathon(res.data); + } + } catch (err) { + console.error('Failed to fetch hackathon details:', err); + } + }; + fetchHackathonDetails(); } }, [hackathonId, fetchSubmissions]); + useEffect(() => { + const fetchSession = async () => { + try { + const { data: session } = await authClient.getSession(); + if (session?.user?.id) { + setCurrentUserId(session.user.id); + } + } catch (err) { + console.error('Failed to fetch session:', err); + } + }; + fetchSession(); + }, []); + if (error) { return (
@@ -84,6 +114,8 @@ export default function SubmissionsPage() { onRefresh={refresh} organizationId={organizationId} hackathonId={hackathonId} + currentUserId={currentUserId || undefined} + hackathon={hackathon || undefined} /> )}
diff --git a/app/(landing)/organizations/[id]/hackathons/page.tsx b/app/(landing)/organizations/[id]/hackathons/page.tsx index 1f0fb3e0f..10529736e 100644 --- a/app/(landing)/organizations/[id]/hackathons/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/page.tsx @@ -41,8 +41,7 @@ const calculateDraftCompletion = (draft: HackathonDraft): number => { draft.data.information?.categories, draft.data.timeline?.startDate, draft.data.timeline?.submissionDeadline, - draft.data.timeline?.judgingDate, - draft.data.timeline?.winnerAnnouncementDate, + draft.data.timeline?.judgingDeadline, draft.data.timeline?.timezone, draft.data.participation?.participantType, draft.data.rewards?.prizeTiers?.length, @@ -73,6 +72,58 @@ const getTimeRemaining = (endDate: string): string => { return `${Math.floor(days / 365)}y`; }; +type HackathonStatus = + | 'DRAFT' + | 'UPCOMING' + | 'ACTIVE' + | 'JUDGING' + | 'COMPLETED' + | 'ARCHIVED' + | 'CANCELLED'; + +const getStatusDisplay = ( + status: HackathonStatus | undefined +): { label: string; className: string } => { + switch (status) { + case 'UPCOMING': + return { + label: 'Upcoming', + className: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + }; + case 'ACTIVE': + return { + label: 'Live', + className: 'bg-green-500/10 text-green-400 border-green-500/20', + }; + case 'JUDGING': + return { + label: 'Judging', + className: 'bg-amber-500/10 text-amber-400 border-amber-500/20', + }; + case 'COMPLETED': + return { + label: 'Ended', + className: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20', + }; + case 'ARCHIVED': + return { + label: 'Archived', + className: 'bg-zinc-600/10 text-zinc-500 border-zinc-600/20', + }; + case 'CANCELLED': + return { + label: 'Cancelled', + className: 'bg-red-500/10 text-red-400 border-red-500/20', + }; + case 'DRAFT': + default: + return { + label: status === 'DRAFT' ? 'Draft' : (status ?? 'Draft'), + className: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20', + }; + } +}; + export default function HackathonsPage() { const params = useParams(); const organizationId = params.id as string; @@ -80,14 +131,13 @@ export default function HackathonsPage() { const [searchQuery, setSearchQuery] = useState(''); const [sortBy, setSortBy] = useState<'newest' | 'oldest'>('newest'); - const [statusFilter, setStatusFilter] = useState<'all' | 'open' | 'draft'>( - 'all' - ); + const [tab, setTab] = useState<'published' | 'drafts'>('published'); const [categoryFilter, setCategoryFilter] = useState('all'); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [hackathonToDelete, setHackathonToDelete] = useState<{ id: string; title: string; + type: 'draft' | 'hackathon'; } | null>(null); const { hackathons, hackathonsLoading, drafts, draftsLoading, refetchAll } = @@ -99,13 +149,12 @@ export default function HackathonsPage() { // Use the separate delete hook const { isDeleting, deleteHackathon } = useDeleteHackathon({ organizationId, - hackathonId: hackathonToDelete?.id || '', // This will be set when we have a hackathon to delete + hackathonId: hackathonToDelete?.id || '', + type: hackathonToDelete?.type ?? 'hackathon', + suppressToast: true, onSuccess: () => { // Refresh the hackathons list after successful deletion refetchAll(); - toast.success('Hackathon deleted successfully', { - description: `"${hackathonToDelete?.title}" has been permanently deleted.`, - }); }, onError: error => { toast.error('Failed to delete hackathon', { @@ -114,93 +163,101 @@ export default function HackathonsPage() { }, }); - const allHackathons = useMemo(() => { - const items: Array<{ - type: 'draft' | 'hackathon'; - data: HackathonDraft | Hackathon; - }> = []; - - drafts.forEach(draft => { - if (statusFilter === 'all' || statusFilter === 'draft') { - items.push({ type: 'draft', data: draft }); - } - }); - - hackathons.forEach(hackathon => { - if (hackathon.status === 'DRAFT') return; - if ( - statusFilter === 'all' || - (statusFilter === 'open' && hackathon.status === 'PUBLISHED') - ) { - items.push({ type: 'hackathon', data: hackathon }); - } + const publishedHackathons = useMemo(() => { + let items = hackathons.filter(h => h.status !== 'DRAFT'); + if (searchQuery) { + const query = searchQuery.toLowerCase(); + items = items.filter(h => h.name?.toLowerCase().includes(query)); + } + if (categoryFilter !== 'all') { + items = items.filter(h => + h.categories + ?.map(c => c.toLowerCase()) + .includes(categoryFilter.toLowerCase()) + ); + } + items = items.sort((a, b) => { + const dateA = new Date(a.createdAt || 0).getTime(); + const dateB = new Date(b.createdAt || 0).getTime(); + return sortBy === 'newest' ? dateB - dateA : dateA - dateB; }); + return items; + }, [hackathons, searchQuery, categoryFilter, sortBy]); - let filtered = items; + const draftHackathons = useMemo(() => { + let items = drafts; if (searchQuery) { const query = searchQuery.toLowerCase(); - filtered = items.filter(item => { - const title = - item.type === 'draft' - ? ( - item.data as HackathonDraft - ).data.information?.name?.toLowerCase() || '' - : (item.data as Hackathon).name?.toLowerCase() || ''; - return title.includes(query); - }); + items = items.filter(d => + d.data.information?.name?.toLowerCase().includes(query) + ); } - if (categoryFilter !== 'all') { - filtered = filtered.filter(item => { - const category = - item.type === 'draft' - ? (item.data as HackathonDraft).data.information?.categories - ?.join(',') - ?.toLowerCase() || '' - : ''; - return category.includes(categoryFilter.toLowerCase()); - }); + items = items.filter(d => + d.data.information?.categories + ?.map(c => c.toLowerCase()) + .includes(categoryFilter.toLowerCase()) + ); } - - filtered.sort((a, b) => { - const dateA = new Date(a.data.createdAt || 0).getTime(); - const dateB = new Date(b.data.createdAt || 0).getTime(); + items = items.sort((a, b) => { + const dateA = new Date(a.createdAt || 0).getTime(); + const dateB = new Date(b.createdAt || 0).getTime(); return sortBy === 'newest' ? dateB - dateA : dateA - dateB; }); - - return filtered; - }, [drafts, hackathons, searchQuery, statusFilter, categoryFilter, sortBy]); + return items; + }, [drafts, searchQuery, categoryFilter, sortBy]); const isLoading = hackathonsLoading || draftsLoading; const stats = useMemo(() => { - const published = hackathons.filter(h => h.status === 'PUBLISHED').length; + const published = hackathons.filter(h => + ['UPCOMING', 'ACTIVE', 'JUDGING', 'COMPLETED'].includes(h.status) + ).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); - if (hackathon) { - const title = - 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); + const handleDeleteClick = ( + hackathonId: string, + type: 'draft' | 'hackathon' + ) => { + if (type === 'hackathon') { + const published = publishedHackathons.find(h => h.id === hackathonId); + if (published) { + setHackathonToDelete({ + id: hackathonId, + title: published.name || 'Untitled Hackathon', + type: 'hackathon', + }); + setDeleteDialogOpen(true); + } + } else { + const draft = draftHackathons.find(d => d.id === hackathonId); + if (draft) { + setHackathonToDelete({ + id: hackathonId, + title: draft.data.information?.name || 'Untitled Hackathon', + type: 'draft', + }); + setDeleteDialogOpen(true); + } } }; const handleDeleteConfirm = async () => { if (!hackathonToDelete) return; + const { title } = hackathonToDelete; // โœ… snapshot before state clears + setDeleteDialogOpen(false); try { await deleteHackathon(); + toast.success('Hackathon deleted successfully', { + description: `"${title}" has been permanently deleted.`, + }); } catch { - // Error handled by toast in deleteHackathon hook + // error toast handled by onError in hook } finally { setHackathonToDelete(null); } @@ -210,11 +267,11 @@ export default function HackathonsPage() { }>
{/* Header */} -
+
-

+

Hackathons

@@ -226,23 +283,39 @@ export default function HackathonsPage() {
- + Host Hackathon
+ {/* Tabs */} +
+ + +
+ {/* Filters */} -
-
+
+
setSearchQuery(e.target.value)} - className='focus:border-primary focus:ring-primary/20 h-10 border-zinc-800/50 bg-zinc-900/30 pl-10 text-sm text-white transition-all placeholder:text-zinc-500 hover:border-zinc-700 hover:bg-zinc-900/50' + className='focus:border-primary focus:ring-primary/20 h-10 rounded-xl border-zinc-800/50 bg-zinc-900/30 pl-10 text-sm text-white transition-all placeholder:text-zinc-500 hover:border-zinc-700 hover:bg-zinc-900/50' />
@@ -250,7 +323,7 @@ export default function HackathonsPage() { value={sortBy} onValueChange={value => setSortBy(value as 'newest' | 'oldest')} > - + @@ -269,39 +342,8 @@ export default function HackathonsPage() { - - setSearchTerm(e.target.value)} - className='bg-backgound-main-bg w-full rounded-lg border-gray-900 py-3 pr-4 pl-10 text-base text-white placeholder-gray-400 focus:border-gray-400 focus:ring-1 focus:ring-gray-400' - /> + {/* Search Input + View Toggle */} +
+
+ + setSearchTerm(e.target.value)} + className='bg-backgound-main-bg w-full rounded-lg border-gray-900 py-3 pr-4 pl-10 text-base text-white placeholder-gray-400 focus:border-gray-400 focus:ring-1 focus:ring-gray-400' + /> +
+ + {/* View mode toggle */} +
+ + +
@@ -219,18 +270,27 @@ const SubmissionTabContent: React.FC = ({

)} - {/* Submissions Grid */} + {/* Submissions Grid / List */} {submissions.length > 0 || mySubmission ? ( -
- {/* Pinned User Submission */} +
+ {/* Pinned User Submission โ€” always SubmissionCard (has edit/delete) */} {mySubmission && ( = ({ submissionId={mySubmission.id} isPinned={true} isMySubmission={true} + isDeadlinePassed={isDeadlinePassed} onViewClick={() => handleViewSubmission(mySubmission.id)} onEditClick={expand} onDeleteClick={() => handleDeleteClick(mySubmission.id)} @@ -274,10 +335,13 @@ const SubmissionTabContent: React.FC = ({ {submissions .filter(submission => - // Filter out my own submission if it's already shown as pinned mySubmission ? submission._id !== mySubmission.id : true ) .map((submission, index) => { + const submissionId = + (submission as { _id?: string; id?: string })?._id || + (submission as { id?: string })?.id; + const status = submission.status?.toLowerCase() === 'shortlisted' ? 'Approved' @@ -285,30 +349,58 @@ const SubmissionTabContent: React.FC = ({ ? 'Rejected' : 'Pending'; + const voteCount = + submission.votes?.current ?? submission.upvotes ?? 0; + const commentCount = submission.comments ?? 0; + + if (viewMode === 'list') { + return ( + handleViewSubmission(submissionId)} + onUpvoteClick={() => { + if (!isAuthenticated) return; + handleUpvoteSubmission(submissionId); + }} + onCommentClick={() => { + if (!isAuthenticated) return; + handleCommentSubmission(submissionId); + }} + /> + ); + } + return ( - handleViewSubmission((submission as { _id?: string })?._id) - } + upvotes={voteCount} + comments={commentCount} + submittedDate={submission.submittedDate} + image={submission.logo ?? '/placeholder.svg'} + submissionId={submissionId} + onViewClick={() => handleViewSubmission(submissionId)} onUpvoteClick={() => { - if (!isAuthenticated) { - return; - } - handleUpvoteSubmission( - (submission as { _id?: string })?._id - ); + if (!isAuthenticated) return; + handleUpvoteSubmission(submissionId); }} onCommentClick={() => { - if (!isAuthenticated) { - return; - } - handleCommentSubmission( - (submission as { _id?: string })?._id - ); + if (!isAuthenticated) return; + handleCommentSubmission(submissionId); }} /> ); @@ -387,6 +479,7 @@ const SubmissionTab: React.FC = ({ }) => { const { currentHackathon } = useHackathonData(); const hackathonId = currentHackathon?.id || ''; + const hackathonSlug = currentHackathon?.slug || ''; const orgId = organizationId || undefined; const { isAuthenticated } = useAuthStatus(); @@ -396,13 +489,13 @@ const SubmissionTab: React.FC = ({ fetchMySubmission, remove: removeSubmission, } = useSubmission({ - hackathonSlugOrId: hackathonId || '', + hackathonSlugOrId: hackathonSlug || '', autoFetch: isAuthenticated && !!hackathonId, }); return ( { +export const WinnersTab = ({ winners }: WinnersTabProps) => { if (!winners || winners.length === 0) { return (
@@ -28,146 +41,230 @@ export const WinnersTab = ({ winners, hackathonSlug }: WinnersTabProps) => { ); } - // Sort by rank just in case + // Sort by rank const sortedWinners = [...winners].sort((a, b) => a.rank - b.rank); + // Split into podium (1-3) and others + const podiumWinners = sortedWinners.filter(w => w.rank <= 3); + const otherWinners = sortedWinners.filter(w => w.rank > 3); + + // Reorder podium to 2, 1, 3 for visual hierarchy + const getPodiumOrder = (winners: HackathonWinner[]) => { + if (winners.length < 2) return winners; + const first = winners.find(w => w.rank === 1); + const second = winners.find(w => w.rank === 2); + const third = winners.find(w => w.rank === 3); + + return [second, first, third].filter( + (w): w is HackathonWinner => w !== undefined + ); + }; + + const podiumToDisplay = getPodiumOrder(podiumWinners); + + const getPodiumGridCols = (count: number) => { + if (count === 1) return 'mx-auto max-w-sm grid-cols-1'; + if (count === 2) return 'mx-auto max-w-2xl grid-cols-1 md:grid-cols-2'; + return 'grid-cols-1 md:grid-cols-3'; + }; + return ( -
-
- {sortedWinners.map((winner, index) => ( - - ))} -
+
+ {/* Podium Section */} + {podiumToDisplay.length > 0 && ( +
+ {podiumToDisplay.map(winner => ( + + ))} +
+ )} + + {/* Grid Section for Ranks 4+ */} + {otherWinners.length > 0 && ( +
+ {otherWinners.map(winner => ( + + ))} +
+ )}
); }; const WinnerCard = ({ winner, - index, - hackathonSlug, + isPodium = false, }: { winner: HackathonWinner; - index: number; - hackathonSlug?: string; + isPodium?: boolean; }) => { - const getRankIcon = (rank: number) => { - switch (rank) { - case 1: - return ; - case 2: - return ; - case 3: - return ; - default: - return ; - } + const getScaleClass = () => { + if (!isPodium) return ''; + if (winner.rank === 1) return 'md:scale-110 z-10'; + return 'md:scale-95 opacity-90'; }; - const getRankColor = (rank: number) => { - switch (rank) { - case 1: - return 'border-yellow-500/50 bg-yellow-500/5 hover:border-yellow-500'; - case 2: - return 'border-gray-300/50 bg-gray-300/5 hover:border-gray-300'; - case 3: - return 'border-amber-600/50 bg-amber-600/5 hover:border-amber-600'; - default: - return 'border-white/10 bg-white/5 hover:border-white/20'; - } - }; + const projectUrl = winner.submissionId + ? `/projects/${winner.submissionId}?type=submission` + : winner.slug + ? `/projects/${winner.slug}` + : winner.projectId + ? `/projects/${winner.projectId}` + : null; - const CardContent = ( -
-
-
- {getRankIcon(winner.rank)} -
-
- Rank #{winner.rank} -
-
+ const { primaryColor, secondaryColor } = getRibbonColors(winner.rank); -

- {winner.projectName} -

- -
-
- {winner.participants.map((p, i) => ( -
- {p.avatar ? ( - {p.username} - ) : ( -
- {p.username.charAt(0).toUpperCase()} -
- )} -
- ))} + const ProjectContent = ( +
+
+
+ {/* Fallback to trophy if no project image is available in HackathonWinner type */} +
+ +
- - {winner.teamName || - (winner.participants.length === 1 - ? winner.participants[0].username - : 'Team')} - -
- -
-
- Prize - {winner.prize} +
+ + +

+ {winner.projectName} +

+
+ +

{winner.projectName}

+
+
+ + Project + +
+
+ Submission +
+
); - if (winner.projectId) { - return ( - - - {CardContent} - - - ); - } - return ( - {CardContent} + {/* Prize Header */} +
+
+ + + {(() => { + const prize = winner.prize || ''; + const match = prize.match( + /^(?:USDC)?\s*(\d+(?:\.\d+)?)\s*(?:USDC)?$/i + ); + return match ? `${match[1]} USDC` : prize; + })()} + +
+
+ + {/* Ranks/Participants */} +
+
+ {winner.participants.map((p, i) => { + const profileUrl = p.username ? `/profile/${p.username}` : null; + const AvatarElement = ( + + + + {p.username?.charAt(0) || '?'} + + + ); + + return profileUrl ? ( + + {AvatarElement} + + ) : ( +
+ {AvatarElement} +
+ ); + })} +
+ + {/* Ribbon */} +
+ +
+ {getRibbonText(winner.rank)} +
+
+ + {/* Team/Participant Name */} +
+

+ {winner.teamName || + (winner.participants.length === 1 && + winner.participants[0].username ? ( + + {winner.participants[0].username} + + ) : ( + 'Team' + ))} +

+
+
+ + {/* Project Link */} + {projectUrl ? ( + + {ProjectContent} + + ) : ( +
{ProjectContent}
+ )}
); }; diff --git a/components/landing-page/Hero2.tsx b/components/landing-page/Hero2.tsx index 69df7149d..2db523d0b 100644 --- a/components/landing-page/Hero2.tsx +++ b/components/landing-page/Hero2.tsx @@ -133,7 +133,7 @@ export default function Hero2() { name: 'Boundless', logo: '/bitmed.png', }, - status: 'PUBLISHED' as const, + status: 'ACTIVE' as const, isActive: true, isParticipant: false, venueType: 'VIRTUAL' as const, @@ -143,16 +143,12 @@ export default function Hero2() { state: 'CA', country: 'USA', timezone: 'UTC', - startDate: '2024-01-01', - endDate: '2024-01-01', - submissionDeadline: '2024-01-01', - registrationDeadline: '2024-01-01', - customRegistrationDeadline: '2024-01-01', + startDate: '2024-05-01T09:00:00Z', + submissionDeadline: '2024-06-01T23:59:59Z', + registrationDeadline: '2024-05-15T23:59:59Z', + judgingDeadline: '2024-06-10T18:00:00Z', registrationOpen: true, - registrationDeadlinePolicy: - 'BEFORE_SUBMISSION_DEADLINE' as const, - daysUntilStart: 10, - daysUntilEnd: 10, + daysUntilStart: 0, participantType: 'INDIVIDUAL' as const, teamMin: 1, teamMax: 4, @@ -180,7 +176,7 @@ export default function Hero2() { prizeTiers: [ { id: '1', - place: '1', + name: '1', prizeAmount: '50000', description: 'For the best project', }, @@ -293,7 +289,7 @@ export default function Hero2() { name: 'Boundless', logo: '/bitmed.png', }, - status: 'PUBLISHED' as const, + status: 'UPCOMING' as const, isActive: true, isParticipant: false, venueType: 'PHYSICAL' as const, @@ -303,16 +299,12 @@ export default function Hero2() { state: 'California', country: 'United States', timezone: 'America/Los_Angeles', - startDate: '2026-01-01', - endDate: '2026-01-01', - submissionDeadline: '2026-01-01', - registrationDeadline: '2026-01-01', - customRegistrationDeadline: '2026-01-01', + startDate: '2026-06-01T10:00:00Z', + submissionDeadline: '2026-06-25T23:59:59Z', + registrationDeadline: '2026-05-25T23:59:59Z', + judgingDeadline: '2026-06-28T18:00:00Z', registrationOpen: true, - registrationDeadlinePolicy: - 'BEFORE_SUBMISSION_DEADLINE' as const, - daysUntilStart: 10, - daysUntilEnd: 10, + daysUntilStart: 100, participantType: 'INDIVIDUAL' as const, teamMin: 1, teamMax: 4, @@ -340,7 +332,7 @@ export default function Hero2() { prizeTiers: [ { id: '1', - place: '1st Place', + name: '1st Place', prizeAmount: '50000', description: 'For the best project', }, @@ -349,8 +341,8 @@ export default function Hero2() { { id: '1', name: 'Phase 1', - startDate: '2026-01-01', - endDate: '2026-01-01', + startDate: '2026-06-01T10:00:00Z', + endDate: '2026-06-30T17:00:00Z', }, ], resources: [], diff --git a/components/landing-page/blog/BlogCard.tsx b/components/landing-page/blog/BlogCard.tsx index 2caeb8282..586a3d15e 100644 --- a/components/landing-page/blog/BlogCard.tsx +++ b/components/landing-page/blog/BlogCard.tsx @@ -5,21 +5,18 @@ import { CardHeader, } from '@/components/ui/card'; import Image from 'next/image'; -import { BlogPost } from '@/types/blog'; +import { MdxBlogPost } from '@/lib/mdx'; import { Badge } from '@/components/ui/badge'; import { ArrowRight, Clock } from 'lucide-react'; interface BlogCardProps { - post: BlogPost; + post: MdxBlogPost; onCardClick?: (slug: string) => void; } const BlogCard = ({ post, onCardClick }: BlogCardProps) => { return ( - + {/* Image Header with 2:1 Aspect Ratio */}
diff --git a/components/landing-page/blog/BlogGrid.tsx b/components/landing-page/blog/BlogGrid.tsx index ab00de405..a5405d409 100644 --- a/components/landing-page/blog/BlogGrid.tsx +++ b/components/landing-page/blog/BlogGrid.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useState, useCallback, useMemo } from 'react'; -import { BlogPost } from '@/types/blog'; +import { MdxBlogPost } from '@/lib/mdx'; import BlogCard from './BlogCard'; import { Search, Loader2 } from 'lucide-react'; import { Input } from '@/components/ui/input'; @@ -16,7 +16,7 @@ import { import { cn } from '@/lib/utils'; interface BlogGridProps { - posts: BlogPost[]; + posts: MdxBlogPost[]; showLoadMore?: boolean; maxPosts?: number; totalPosts?: number; @@ -29,7 +29,7 @@ const BlogGrid: React.FC = ({ maxPosts, initialPage = 1, }) => { - const [allPosts, setAllPosts] = useState(posts); + const [allPosts, setAllPosts] = useState(posts); const [currentPage, setCurrentPage] = useState(initialPage); const [visiblePosts, setVisiblePosts] = useState(maxPosts || 12); const [selectedCategories, setSelectedCategories] = useState([]); @@ -64,16 +64,15 @@ const BlogGrid: React.FC = ({ post => post.title.toLowerCase().includes(query) || post.excerpt.toLowerCase().includes(query) || - (post.tags && - post.tags.some(tag => tag.tag.name.toLowerCase().includes(query))) + post.tags.some(tag => tag.toLowerCase().includes(query)) ); } // Sort posts if (sortOrder) { filtered = [...filtered].sort((a, b) => { - const dateA = new Date(a.createdAt).getTime(); - const dateB = new Date(b.createdAt).getTime(); + const dateA = new Date(a.publishedAt).getTime(); + const dateB = new Date(b.publishedAt).getTime(); return sortOrder === 'Latest' ? dateB - dateA : dateA - dateB; }); } @@ -145,11 +144,8 @@ const BlogGrid: React.FC = ({ }; const handleCardClick = useCallback((slug: string) => { + void slug; 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); }, 2000); // Fallback timeout @@ -311,7 +307,7 @@ const BlogGrid: React.FC = ({ {displayPosts.length > 0 ? (
{displayPosts.map(post => ( -
+
))} diff --git a/components/landing-page/blog/BlogPostDetails.tsx b/components/landing-page/blog/BlogPostDetails.tsx index d538c7dbb..5569d6428 100644 --- a/components/landing-page/blog/BlogPostDetails.tsx +++ b/components/landing-page/blog/BlogPostDetails.tsx @@ -1,52 +1,30 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import Image from 'next/image'; import { Tag, BookOpen, Check } from 'lucide-react'; -import { BlogPost } from '@/types/blog'; +import { MdxBlogPost } from '@/lib/mdx'; 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'; +import { useRouter } from 'next/navigation'; +import { useTransition, useCallback } from 'react'; +import './prism-theme.css'; interface BlogPostDetailsProps { - post: BlogPost; + post: MdxBlogPost & { content: React.ReactElement }; + relatedPosts: MdxBlogPost[]; } -const BlogPostDetails: React.FC = ({ post }) => { - const { loading, error, styledContent } = useMarkdown(post.content, { - breaks: true, - gfm: true, - pedantic: true, - loadingDelay: 100, - }); - - const [relatedPosts, setRelatedPosts] = useState([]); +const BlogPostDetails: React.FC = ({ + post, + relatedPosts, +}) => { const [copiedStates, setCopiedStates] = useState>({}); - const [isLoadingRelated, setIsLoadingRelated] = useState(true); const [isNavigating, setIsNavigating] = useState(false); - const [relatedPostsError, setRelatedPostsError] = useState( - null - ); - - useEffect(() => { - const fetchRelatedPosts = async () => { - try { - setIsLoadingRelated(true); - setRelatedPostsError(null); - const related = await getRelatedPosts(post.id); - setRelatedPosts(related.posts); - } catch { - setRelatedPostsError('Failed to load related posts'); - setRelatedPosts([]); - } finally { - setIsLoadingRelated(false); - } - }; - fetchRelatedPosts(); - }, [post.slug]); + const [isPending, startTransition] = useTransition(); + const router = useRouter(); const formatDate = (dateString: string) => { const date = new Date(dateString); @@ -104,20 +82,22 @@ const BlogPostDetails: React.FC = ({ post }) => { } }; - const handleRelatedPostClick = (slug: string) => { - setIsNavigating(true); - // The navigation will be handled by Next.js Link, but we show loading state - // eslint-disable-next-line no-console - console.log(`Navigating to related post: ${slug}`); - setTimeout(() => { - setIsNavigating(false); - }, 2000); // Fallback timeout - }; + const handleRelatedPostClick = useCallback( + (slug: string) => { + setIsNavigating(true); + startTransition(() => { + router.push(`/blog/${slug}`); + }); + }, + [router] + ); return ( <> - {isNavigating && } -
+ {(isNavigating || isPending) && ( + + )} +
@@ -139,7 +119,7 @@ const BlogPostDetails: React.FC = ({ post }) => {
- {formatDate(post.createdAt)} + {formatDate(post.publishedAt)}
@@ -168,22 +148,8 @@ const BlogPostDetails: React.FC = ({ post }) => {
-
- {loading ? ( -
-
-
- Loading content... -
-
- ) : error ? ( -
-

Error loading content:

-

{error}

-
- ) : ( - styledContent - )} +
+ {post.content}
@@ -193,12 +159,12 @@ const BlogPostDetails: React.FC = ({ post }) => { {post.tags?.map(tag => ( - {tag.tag.name} + {tag} ))}
@@ -311,22 +277,7 @@ const BlogPostDetails: React.FC = ({ post }) => {

Related Articles

- {isLoadingRelated ? ( -
- {[...Array(3)].map((_, index) => ( -
-
-
-
-
- ))} -
- ) : relatedPostsError ? ( -
- -

{relatedPostsError}

-
- ) : relatedPosts && relatedPosts.length > 0 ? ( + {relatedPosts.length > 0 ? (
{relatedPosts.map(relatedPost => ( { - try { - const response = await getBlogPosts({ - page: 1, - limit: 6, - sortBy: 'createdAt', - sortOrder: 'desc', - status: 'PUBLISHED', - }); - - return ; - } catch { - return ; - } +const BlogSection = () => { + const posts = getAllBlogPosts().slice(0, 6); + return ; }; export default BlogSection; diff --git a/components/landing-page/blog/BlogSectionClient.tsx b/components/landing-page/blog/BlogSectionClient.tsx index ccb8fac89..53ec3235e 100644 --- a/components/landing-page/blog/BlogSectionClient.tsx +++ b/components/landing-page/blog/BlogSectionClient.tsx @@ -6,10 +6,10 @@ import { useRouter } from 'next/navigation'; import { useCallback, useState, useTransition } from 'react'; import AuthLoadingState from '@/components/auth/AuthLoadingState'; import BlogCard from './BlogCard'; -import { BlogPost } from '@/types/blog'; +import { MdxBlogPost } from '@/lib/mdx'; interface BlogSectionClientProps { - posts: BlogPost[]; + posts: MdxBlogPost[]; } const BlogSectionClient = ({ posts }: BlogSectionClientProps) => { @@ -71,7 +71,7 @@ const BlogSectionClient = ({ posts }: BlogSectionClientProps) => { aria-label='Blog posts grid' > {posts.slice(0, 6).map(blog => ( -
+
))} diff --git a/components/landing-page/blog/MdxComponents.tsx b/components/landing-page/blog/MdxComponents.tsx new file mode 100644 index 000000000..65d8728f3 --- /dev/null +++ b/components/landing-page/blog/MdxComponents.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { Badge } from '@/components/ui/badge'; +import MermaidChart from './MermaidChart'; +function MdxCode({ + children, + className, + ...props +}: React.ComponentProps<'code'>) { + // Inline code (no className) vs fenced code block (has language-* className) + const isBlock = !!className; + if (!isBlock) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); +} + +function MdxPre({ children, ...props }: React.ComponentProps<'pre'>) { + return ( +
+      {children}
+    
+ ); +} + +// --------------------------------------------------------------------------- +// Table +// --------------------------------------------------------------------------- +function MdxTable({ children, ...props }: React.ComponentProps<'table'>) { + return ( +
+ + {children} +
+
+ ); +} + +function MdxThead({ children, ...props }: React.ComponentProps<'thead'>) { + return ( + + {children} + + ); +} + +function MdxTh({ children, ...props }: React.ComponentProps<'th'>) { + return ( + + {children} + + ); +} + +function MdxTd({ children, ...props }: React.ComponentProps<'td'>) { + return ( + + {children} + + ); +} + +function MdxTr({ children, ...props }: React.ComponentProps<'tr'>) { + return ( + + {children} + + ); +} + +function MdxTbody({ children, ...props }: React.ComponentProps<'tbody'>) { + return {children}; +} + +// --------------------------------------------------------------------------- +// Mermaid diagram โ€” client component that renders with mermaid.js +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Exported component map โ€” passed to compileMDX +// --------------------------------------------------------------------------- +export const mdxComponents = { + // HTML element overrides + pre: MdxPre, + code: MdxCode, + table: MdxTable, + thead: MdxThead, + tbody: MdxTbody, + th: MdxTh, + td: MdxTd, + tr: MdxTr, + // Named components usable inside .mdx files as / + Badge, + Mermaid: MermaidChart, +}; diff --git a/components/landing-page/blog/MermaidChart.tsx b/components/landing-page/blog/MermaidChart.tsx new file mode 100644 index 000000000..befb27a5b --- /dev/null +++ b/components/landing-page/blog/MermaidChart.tsx @@ -0,0 +1,101 @@ +'use client'; + +import React, { useEffect, useId, useState } from 'react'; + +function getTextContent(children: React.ReactNode): string { + if (typeof children === 'string') return children.trim(); + if (Array.isArray(children)) + return children.map(getTextContent).join('').trim(); + if (children && typeof children === 'object' && 'props' in children) { + return getTextContent( + (children as React.ReactElement<{ children?: React.ReactNode }>).props + .children + ); + } + return String(children ?? '').trim(); +} + +interface MermaidChartProps { + children?: React.ReactNode; +} + +const MermaidChart: React.FC = ({ children }) => { + const id = useId().replace(/:/g, ''); + const containerId = `mermaid-${id}`; + const [error, setError] = useState(null); + const [svg, setSvg] = useState(null); + + const source = getTextContent(children); + + useEffect(() => { + if (!source) return; + + let cancelled = false; + + const run = async () => { + try { + const mermaid = (await import('mermaid')).default; + mermaid.initialize({ + startOnLoad: false, + theme: 'dark', + securityLevel: 'loose', + }); + const { svg: rendered } = await mermaid.render(containerId, source); + if (!cancelled) { + setError(null); + setSvg(rendered); + } + } catch (err) { + if (!cancelled) { + setError( + err instanceof Error ? err.message : 'Mermaid render failed' + ); + setSvg(null); + } + } + }; + + run(); + return () => { + cancelled = true; + }; + }, [source, containerId]); + + if (error) { + return ( +
+

+ Diagram (render failed) +

+
+          {source}
+        
+

+ {error} +

+
+ ); + } + + if (svg) { + return ( +
+ ); + } + + return ( +
+

+ Diagram +

+
+        {source}
+      
+
+ ); +}; + +export default MermaidChart; diff --git a/components/landing-page/blog/StreamingBlogGrid.tsx b/components/landing-page/blog/StreamingBlogGrid.tsx index 1ba8f7440..456abd0ba 100644 --- a/components/landing-page/blog/StreamingBlogGrid.tsx +++ b/components/landing-page/blog/StreamingBlogGrid.tsx @@ -1,10 +1,10 @@ 'use client'; import React, { useState, useCallback, useMemo, useTransition } from 'react'; -import { BlogPost, GetBlogPostsResponse } from '@/types/blog'; +import { MdxBlogPost } from '@/lib/mdx'; import { useRouter } from 'next/navigation'; import BlogCard from './BlogCard'; -import { Search, Loader2 } from 'lucide-react'; +import { Search } from 'lucide-react'; import { Input } from '@/components/ui/input'; import AuthLoadingState from '@/components/auth/AuthLoadingState'; import { @@ -16,29 +16,21 @@ import { } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; +const PAGE_SIZE = 12; + interface StreamingBlogGridProps { - initialPosts: BlogPost[]; - hasMore: boolean; - initialPage: number; - totalPages: number; - onLoadMore: (page: number) => Promise; + initialPosts: MdxBlogPost[]; } 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 [allPosts] = useState(initialPosts); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); 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 [error, setError] = useState(null); const [isPending, startTransition] = useTransition(); const router = useRouter(); @@ -65,15 +57,14 @@ const StreamingBlogGrid: React.FC = ({ post => post.title.toLowerCase().includes(query) || post.excerpt.toLowerCase().includes(query) || - (post.tags && - post.tags.some(tag => tag.tag.name.toLowerCase().includes(query))) + post.tags.some(tag => tag.toLowerCase().includes(query)) ); } if (sortOrder) { filtered = [...filtered].sort((a, b) => { - const dateA = new Date(a.coverImage).getTime(); - const dateB = new Date(b.createdAt).getTime(); + const dateA = new Date(a.publishedAt).getTime(); + const dateB = new Date(b.publishedAt).getTime(); return sortOrder === 'Latest' ? dateB - dateA : dateA - dateB; }); } @@ -81,31 +72,17 @@ const StreamingBlogGrid: React.FC = ({ return filtered; }, [allPosts, selectedCategories, searchQuery, sortOrder]); - const displayPosts = filteredPosts; - const hasMorePostsToShow = - hasMore && !searchQuery && selectedCategories.length === 0; + const isFiltering = !!searchQuery || selectedCategories.length > 0; + const displayPosts = isFiltering + ? filteredPosts + : filteredPosts.slice(0, visibleCount); + const hasMoreToShow = !isFiltering && visibleCount < filteredPosts.length; const sortOptions: Array<'Latest' | 'Oldest'> = ['Latest', 'Oldest']; - const handleLoadMore = useCallback(async () => { - if (isLoading || !hasMore) return; - - setIsLoading(true); - setError(null); - - try { - const nextPage = currentPage + 1; - const response = await onLoadMore(nextPage); - - 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, onLoadMore]); + const handleShowMore = useCallback(() => { + setVisibleCount(prev => prev + PAGE_SIZE); + }, []); const handleCategoryChange = (category: string) => { setSelectedCategories(prev => @@ -284,16 +261,10 @@ const StreamingBlogGrid: React.FC = ({
- {error && ( -
- {error} -
- )} - {displayPosts.length > 0 ? (
{displayPosts.map(post => ( -
+
))} @@ -316,10 +287,10 @@ const StreamingBlogGrid: React.FC = ({
)} - {hasMorePostsToShow && !isLoading && ( + {hasMoreToShow && (
)} - {isLoading && ( -
-
- - Loading more posts... -
-
- )} - - {!hasMore && filteredPosts.length > 0 && !isLoading && ( + {!hasMoreToShow && !isFiltering && filteredPosts.length > 0 && (
-

You've reached the end of the blog posts!

+

You've reached the end of the blog posts!

)}
diff --git a/components/landing-page/blog/prism-theme.css b/components/landing-page/blog/prism-theme.css new file mode 100644 index 000000000..121c3bf56 --- /dev/null +++ b/components/landing-page/blog/prism-theme.css @@ -0,0 +1,81 @@ +/* IDE-style syntax highlighting for MDX code blocks (rehype-prism-plus / refractor) */ +.prose pre[class*='language-'] { + background: var(--color-background-card); + border: 1px solid var(--color-gray-900); +} + +.prose pre[class*='language-'] code { + background: transparent; + padding: 0; + font-size: 0.875rem; + line-height: 1.625; +} + +/* Token colors โ€” IDE-like dark theme using design system */ +.prose .token.comment, +.prose .token.prolog, +.prose .token.doctype, +.prose .token.cdata { + color: var(--color-gray-600); +} + +.prose .token.punctuation { + color: var(--color-gray-500); +} + +.prose .token.property, +.prose .token.tag, +.prose .token.boolean, +.prose .token.number, +.prose .token.constant, +.prose .token.symbol { + color: #d4a574; +} + +.prose .token.selector, +.prose .token.attr-name, +.prose .token.string, +.prose .token.char, +.prose .token.builtin { + color: #7ec699; +} + +.prose .token.operator, +.prose .token.entity, +.prose .token.url, +.prose .token.variable { + color: var(--color-gray-300); +} + +.prose .token.atrule, +.prose .token.keyword { + color: var(--color-primary); +} + +.prose .token.attr-value, +.prose .token.regex, +.prose .token.important { + color: #7ec699; +} + +.prose .token.function, +.prose .token.class-name { + color: #ddb6f2; +} + +.prose .token.deleted { + color: #e26d6d; +} + +.prose .token.inserted { + color: #7ec699; +} + +/* Line numbers (if enabled via rehype-prism-plus meta) */ +.prose .code-line.line-number::before { + color: var(--color-gray-700); +} + +.prose .code-highlight .highlight-line { + background: color-mix(in srgb, var(--color-section) 60%, transparent); +} diff --git a/components/landing-page/hackathon/HackathonCard.tsx b/components/landing-page/hackathon/HackathonCard.tsx index 52d7b17ae..305124acd 100644 --- a/components/landing-page/hackathon/HackathonCard.tsx +++ b/components/landing-page/hackathon/HackathonCard.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useRouter } from 'nextjs-toploader/app'; +import Link from 'next/link'; import Image from 'next/image'; import { MapPinIcon } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { Hackathon } from '@/lib/api/hackathons'; +import { cn } from '@/lib/utils'; // type HackathonCardProps = { // id: string; @@ -117,6 +118,38 @@ import { Hackathon } from '@/lib/api/hackathons'; const formatFullNumber = (num: number): string => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(num); +const MAX_VISIBLE_CATEGORIES = 3; + +interface CategoriesDisplayProps { + categoriesList?: string[]; +} + +const CategoriesDisplay = ({ categoriesList = [] }: CategoriesDisplayProps) => { + const visible = categoriesList.slice(0, MAX_VISIBLE_CATEGORIES); + const remainingCount = categoriesList.length - MAX_VISIBLE_CATEGORIES; + + return ( +
+
+ {visible.map((cat, i) => ( + + {cat} + + ))} + + {remainingCount > 0 && ( + + +{remainingCount} + + )} +
+
+ ); +}; + interface TimeRemaining { days: number; hours: number; @@ -157,7 +190,13 @@ function calculateTimeRemaining(targetDate: string): TimeRemaining { // } // } -function HackathonCard({ +interface HackathonCardProps extends Hackathon { + isFullWidth?: boolean; + className?: string; + target?: string; +} + +export const HackathonCard = ({ id, slug, name, @@ -176,9 +215,9 @@ function HackathonCard({ categories, prizeTiers, isFullWidth = false, - // className, -}: Hackathon & { isFullWidth?: boolean }) { - const router = useRouter(); + className, + target, +}: HackathonCardProps) => { const [timeRemaining, setTimeRemaining] = useState({ days: 0, hours: 0, @@ -187,13 +226,9 @@ function HackathonCard({ total: 0, }); - const handleClick = () => { - const slugPath = slug || id || ''; - router.push(`/hackathons/${slugPath}`); - }; - - // Determine top badge status using raw dates - const getTopBadgeStatus = () => { + // Determine top badge status using raw dates โ€” memoised so it can safely + // appear in the useEffect dependency array without triggering infinite loops. + const getTopBadgeStatus = useCallback(() => { if (status === 'ARCHIVED') { return 'Archived'; } @@ -216,7 +251,7 @@ function HackathonCard({ // Otherwise it's upcoming return 'Upcoming'; - }; + }, [status, startDate, submissionDeadline]); const getTopBadgeColor = () => { const badgeStatus = getTopBadgeStatus(); @@ -332,7 +367,7 @@ function HackathonCard({ return () => clearInterval(interval); } - }, [status, startDate, submissionDeadline]); + }, [startDate, submissionDeadline, getTopBadgeStatus]); const bottomStatusInfo = getBottomStatusInfo(); const topBadgeStatus = getTopBadgeStatus(); @@ -351,44 +386,19 @@ function HackathonCard({ // return undefined; // })(); - const CategoriesDisplay = ({ - categoriesList, - }: { - categoriesList: string[]; - }) => { - const MAX_VISIBLE = 3; - - const visible = categoriesList.slice(0, MAX_VISIBLE); - const remainingCount = categoriesList.length - MAX_VISIBLE; - - return ( -
-
- {visible.map((cat, i) => ( - - {cat} - - ))} - - {remainingCount > 0 && ( - - +{remainingCount} - - )} -
-
- ); - }; + const href = `/hackathons/${slug || id || ''}`; return ( -
{/* Image */}
@@ -399,7 +409,7 @@ function HackathonCard({ className='object-cover transition-transform duration-300 group-hover:scale-105' unoptimized /> -
+
@@ -411,13 +421,17 @@ function HackathonCard({
-
- - {organization.name} - + {organization?.logo && ( +
+ )} + {organization?.name && ( + + {organization.name} + + )}
@@ -442,7 +456,7 @@ function HackathonCard({ }, 0) )} - {prizeTiers[0]?.place || 'Prize'} + Prize Pool
)} {/* {participantsCount && ( @@ -476,8 +490,8 @@ function HackathonCard({ )} */}
-
+ ); -} +}; export default HackathonCard; diff --git a/components/landing-page/navbar.tsx b/components/landing-page/navbar.tsx index 00b6cd84a..aa86df94f 100644 --- a/components/landing-page/navbar.tsx +++ b/components/landing-page/navbar.tsx @@ -26,6 +26,8 @@ import { } from '../ui/dropdown-menu'; import { UserMenu } from '../user/UserMenu'; import { cn } from '@/lib/utils'; +import { getKycImageAndAlt } from '@/lib/kyc-status'; +import type { GetMeResponse } from '@/lib/api/types'; import { BoundlessButton } from '../buttons'; import { useProtectedAction } from '@/hooks/use-protected-action'; import WalletRequiredModal from '@/components/wallet/WalletRequiredModal'; @@ -33,7 +35,6 @@ import { WalletTrigger } from '../wallet/WalletTrigger'; import { NotificationBell } from '../notifications/NotificationBell'; import CreateProjectModal from '@/features/projects/components/CreateProjectModal'; -// Constants const BRAND_COLOR = '#a7f950'; const ACTIONS = { CREATE_PROJECT: 'create project', @@ -48,18 +49,19 @@ const MENU_ITEMS = [ { href: '/blog', label: 'Blog' }, ] as const; -// Types interface UserProfile { firstName?: string | null; image?: string | null; } interface User { + id?: string; username?: string | null; name?: string | null; email?: string | null; image?: string | null; - profile?: UserProfile; + role?: string; + profile?: UserProfile | import('@/lib/api/types').GetMeResponse | null; } export function Navbar() { @@ -72,7 +74,7 @@ export function Navbar() { } return ( -