diff --git a/package.json b/package.json index 8819da90..c9698e0d 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "vercel:yolo": "yarn workspace @se-2/nextjs vercel:yolo", "ipfs": "yarn workspace @se-2/nextjs ipfs", "vercel:login": "yarn workspace @se-2/nextjs vercel:login", - "verify": "yarn hardhat:verify" + "verify": "yarn hardhat:verify", + "update-profiles": "node packages/nextjs/scripts/update-builder-profiles.mjs" }, "packageManager": "yarn@3.2.3", "devDependencies": { diff --git a/packages/nextjs/app/api/builders/profiles/route.ts b/packages/nextjs/app/api/builders/profiles/route.ts new file mode 100644 index 00000000..93744841 --- /dev/null +++ b/packages/nextjs/app/api/builders/profiles/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; + +// Statically define known builder profiles +// This will be built into the application at build time +// and will be automatically updated when new profiles are added to the codebase +const BUILDER_PROFILES = [ + "0x119d9A1ef0D16361284a9661727b363B04B5B0c8", + "0x167142915AD0fAADD84d9741eC253B82aB8625cd", + "0x208B2660e5F62CDca21869b389c5aF9E7f0faE89", + "0x2E15bB8aDF3438F66A6F786679B0bBBBF02A75d5", + "0x2d90C8bE0Df1BA58a66282bc4Ed03b330eBD7911", + "0x3BFbE4E3dCC472E9B1bdFC0c177dE3459Cf769bf", + "0xB24023434c3670E100068C925A87fE8F500d909a", + "0xE00E720798803B8B83379720c42f7A9bE1cCd281", + "0xb216270aFB9DfcD611AFAf785cEB38250863F2C9", + "0xe98540d28F45830E01D237251Bfc4777E69c9A46", +]; + +export const dynamic = "force-dynamic"; // Don't cache this route + +export async function GET() { + try { + // In a real production environment, this endpoint could: + // 1. Fetch from a database + // 2. Check against a predefined list that's updated during CI/CD + // 3. Use server-only code to scan directories in a non-serverless environment + + // For this implementation, we're returning the predefined list + // that will be populated during build time + + // Get all directories in /app/builders/[address] automatically via Next.js route conventions + const builderProfiles = BUILDER_PROFILES; + + return NextResponse.json({ profiles: builderProfiles }, { status: 200 }); + } catch (error) { + console.error("Error fetching builder profiles:", error); + return NextResponse.json({ profiles: [], error: "Failed to fetch profiles" }, { status: 500 }); + } +} diff --git a/packages/nextjs/app/builders/components/BuilderDetailsRow.tsx b/packages/nextjs/app/builders/components/BuilderDetailsRow.tsx new file mode 100644 index 00000000..8ddc4c8b --- /dev/null +++ b/packages/nextjs/app/builders/components/BuilderDetailsRow.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { Address as AddressType } from "viem"; +import { usePublicClient } from "wagmi"; +import { Address } from "~~/components/scaffold-eth"; +import { useScaffoldReadContract } from "~~/hooks/scaffold-eth"; + +type BuilderDetailsRowProps = { + builderAddress: AddressType; + checkInContractAddress: AddressType; + blockNumber: bigint; + existingProfiles: string[]; +}; + +/** + * Displays a single builder's information including address, contract, and graduation status + */ +export const BuilderDetailsRow = ({ + builderAddress, + checkInContractAddress, + blockNumber, + existingProfiles, +}: BuilderDetailsRowProps) => { + const publicClient = usePublicClient(); + const [checkInDate, setCheckInDate] = useState("Fetching date..."); + + const { data: graduatedTokenId } = useScaffoldReadContract({ + contractName: "BatchRegistry", + functionName: "graduatedTokenId", + args: [builderAddress], + }); + + const hasGraduated = useMemo(() => graduatedTokenId && Number(graduatedTokenId) > 0, [graduatedTokenId]); + + const hasProfile = useMemo(() => { + return existingProfiles.includes(builderAddress); + }, [builderAddress, existingProfiles]); + + useEffect(() => { + const fetchBlockTimestamp = async () => { + if (!publicClient || blockNumber === undefined) { + setCheckInDate("N/A"); + return; + } + try { + const block = await publicClient.getBlock({ blockNumber }); + const date = new Date(Number(block.timestamp) * 1000); + setCheckInDate(date.toLocaleDateString()); + } catch (error) { + console.error(`Error fetching block timestamp for block ${blockNumber}:`, error); + setCheckInDate("Error"); + } + }; + fetchBlockTimestamp(); + }, [publicClient, blockNumber]); + + return ( +
+
+ {/* Top section: Builder EOA and Check-in Date */} +
+
+
+
+
+ Checked in: {checkInDate} +
+
+ + {/* Divider (for visual separation) */} +
+ + {/* Details Section */} +
+
+ Contract: +
+
+
+ Status: + {hasGraduated ? ( + + Graduated (ID: {graduatedTokenId?.toString()}) + + ) : ( + Not Graduated + )} +
+
+ + {hasProfile && ( +
+ + View Profile + +
+ )} +
+
+ ); +}; diff --git a/packages/nextjs/app/builders/components/BuilderListManager.tsx b/packages/nextjs/app/builders/components/BuilderListManager.tsx new file mode 100644 index 00000000..933b7ac4 --- /dev/null +++ b/packages/nextjs/app/builders/components/BuilderListManager.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { BuilderDetailsRow } from "./BuilderDetailsRow"; +import { Address as AddressType } from "viem"; +import { useScaffoldEventHistory } from "~~/hooks/scaffold-eth"; + +export const BuilderListManager = () => { + const { + data: checkedInEvents, + isLoading: isLoadingEvents, + error: errorEvents, + } = useScaffoldEventHistory({ + contractName: "BatchRegistry", + eventName: "CheckedIn", + fromBlock: 334314026n, // DEPLOY_BLOCK + watch: true, + }); + + const buildersWithFirstCheckInBlock = useMemo(() => { + if (!checkedInEvents || !Array.isArray(checkedInEvents) || checkedInEvents.length === 0) return []; + + try { + // Filter out any undefined events + const validEvents = checkedInEvents.filter(event => !!event); + + // Sort events chronologically (earliest block first) + const sortedEvents = [...validEvents].sort((a, b) => { + if (!a || !b || a.blockNumber === undefined || b.blockNumber === undefined) return 0; + const blockA = a.blockNumber; + const blockB = b.blockNumber; + return blockA < blockB ? -1 : blockA > blockB ? 1 : 0; + }); + + const firstCheckIns = new Map(); + + for (const event of sortedEvents) { + if (!event || !event.args) continue; + + const builderAddress = event.args.builder as AddressType | undefined; + const contractAddress = event.args.checkInContract as AddressType | undefined; + const blockNumber = event.blockNumber; + + if ( + builderAddress && + contractAddress && + blockNumber !== undefined && + blockNumber !== null && + !firstCheckIns.has(builderAddress) + ) { + firstCheckIns.set(builderAddress, { + blockNumber, + checkInContract: contractAddress, + }); + } + } + + return Array.from(firstCheckIns.entries()).map(([address, data]) => ({ + address, + blockNumber: data.blockNumber, + checkInContract: data.checkInContract, + })); + } catch (error) { + console.error("Error processing check-in events:", error); + return []; + } + }, [checkedInEvents]); + + const [profilesList, setProfilesList] = useState([]); + const [isLoadingProfiles, setIsLoadingProfiles] = useState(true); + const [profilesError, setProfilesError] = useState(null); + + useEffect(() => { + // Fetch builder profiles from the API route + const fetchProfiles = async () => { + try { + setIsLoadingProfiles(true); + setProfilesError(null); + const response = await fetch("/api/builders/profiles"); + + if (!response.ok) { + throw new Error(`API responded with status: ${response.status}`); + } + + const data = await response.json(); + setProfilesList(data.profiles || []); + } catch (error) { + console.error("Failed to fetch existing profiles:", error); + setProfilesError("Could not load profile data. Profile links may be unavailable."); + } finally { + setIsLoadingProfiles(false); + } + }; + + fetchProfiles(); + }, []); + + if (isLoadingEvents || isLoadingProfiles) { + return ( +
+ +

Loading builders data...

+
+ ); + } + + if (errorEvents) { + return ( +
+ Error loading events. (Message: {errorEvents.message}) +
+ ); + } + + if (profilesError) { + return ( +
+ {profilesError} +
+ ); + } + + return ( +
+ {buildersWithFirstCheckInBlock.length === 0 ? ( +
+
+

+ No builders have checked in yet. +

+
+
+ ) : ( + buildersWithFirstCheckInBlock.map(({ address, blockNumber, checkInContract }) => ( + + )) + )} +
+ ); +}; diff --git a/packages/nextjs/app/builders/page.tsx b/packages/nextjs/app/builders/page.tsx new file mode 100644 index 00000000..da976973 --- /dev/null +++ b/packages/nextjs/app/builders/page.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { BuilderListManager } from "./components/BuilderListManager"; +import { useScaffoldReadContract } from "~~/hooks/scaffold-eth"; + +const BuildersList = () => { + const { data: checkedInCounter } = useScaffoldReadContract({ + contractName: "BatchRegistry", + functionName: "checkedInCounter", + }); + + return ( +
+
+

Batch 16 Builders

+

Checked-in Members

+

+ Total Checked In: {checkedInCounter === undefined ? "..." : (checkedInCounter?.toString() ?? "0")} +

+
+ +
+ +
+
+ ); +}; + +export default BuildersList; diff --git a/packages/nextjs/components/Header.tsx b/packages/nextjs/components/Header.tsx index fba0c29a..42c981a9 100644 --- a/packages/nextjs/components/Header.tsx +++ b/packages/nextjs/components/Header.tsx @@ -5,7 +5,7 @@ import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { hardhat } from "viem/chains"; -import { Bars3Icon, BugAntIcon } from "@heroicons/react/24/outline"; +import { Bars3Icon, BugAntIcon, UserGroupIcon } from "@heroicons/react/24/outline"; import { FaucetButton, RainbowKitCustomConnectButton } from "~~/components/scaffold-eth"; import { useOutsideClick, useTargetNetwork } from "~~/hooks/scaffold-eth"; @@ -20,6 +20,11 @@ export const menuLinks: HeaderMenuLink[] = [ label: "Home", href: "/", }, + { + label: "Builders", + href: "/builders", + icon: , + }, { label: "Debug Contracts", href: "/debug", diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index d094baa3..36fc53a3 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -3,17 +3,18 @@ "private": true, "version": "0.1.0", "scripts": { - "build": "next build", + "build": "node scripts/update-builder-profiles.mjs && next build", "check-types": "tsc --noEmit --incremental", - "dev": "next dev", + "dev": "node scripts/update-builder-profiles.mjs && next dev", "format": "prettier --write . '!(node_modules|.next|contracts)/**/*'", "lint": "next lint", "serve": "next start", - "start": "next dev", + "start": "node scripts/update-builder-profiles.mjs && next dev", "vercel": "vercel --build-env YARN_ENABLE_IMMUTABLE_INSTALLS=false --build-env ENABLE_EXPERIMENTAL_COREPACK=1 --build-env VERCEL_TELEMETRY_DISABLED=1", "vercel:yolo": "vercel --build-env YARN_ENABLE_IMMUTABLE_INSTALLS=false --build-env ENABLE_EXPERIMENTAL_COREPACK=1 --build-env NEXT_PUBLIC_IGNORE_BUILD_ERROR=true --build-env VERCEL_TELEMETRY_DISABLED=1", "ipfs": "NEXT_PUBLIC_IPFS_BUILD=true yarn build && yarn bgipfs upload config init -u https://upload.bgipfs.com && CID=$(yarn bgipfs upload out | grep -o 'CID: [^ ]*' | cut -d' ' -f2) && [ ! -z \"$CID\" ] && echo '🚀 Upload complete! Your site is now available at: https://community.bgipfs.com/ipfs/'$CID || echo '❌ Upload failed'", - "vercel:login": "vercel login" + "vercel:login": "vercel login", + "update-profiles": "node scripts/update-builder-profiles.mjs" }, "dependencies": { "@heroicons/react": "^2.1.5", @@ -28,7 +29,7 @@ "lucide-react": "^0.510.0", "next": "^15.2.3", "next-nprogress-bar": "^2.3.13", -"next-themes": "^0.3.0", + "next-themes": "^0.3.0", "qrcode.react": "^4.0.1", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/nextjs/scripts/update-builder-profiles.mjs b/packages/nextjs/scripts/update-builder-profiles.mjs new file mode 100755 index 00000000..2fc52437 --- /dev/null +++ b/packages/nextjs/scripts/update-builder-profiles.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node + +/** + * This script scans the builders directory for profile pages + * and updates the API route with the list of found profiles. + * + * It should be run during the build process. + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// Get current file directory in ESM +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Define paths +const buildersDir = path.join(__dirname, '..', 'app', 'builders'); +const apiRoutePath = path.join(__dirname, '..', 'app', 'api', 'builders', 'profiles', 'route.ts'); + +// Regular expression for Ethereum addresses +const ETH_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; + +console.log('Scanning builder profiles...'); + +// Check if builders directory exists +if (!fs.existsSync(buildersDir)) { + console.log('Builders directory not found.'); + process.exit(0); +} + +// Check if a directory is a valid builder profile +// 1. Must have a name that looks like an Ethereum address +// 2. Must contain a page.tsx or page.js file +function isValidBuilderProfile(dirName) { + // Skip non-address-looking directories + if (!ETH_ADDRESS_REGEX.test(dirName)) { + return false; + } + + // Check if directory contains a page component + const dirPath = path.join(buildersDir, dirName); + + return ( + fs.existsSync(path.join(dirPath, 'page.tsx')) || + fs.existsSync(path.join(dirPath, 'page.js')) + ); +} + +// Scan for builder profiles (directories in the builders folder that contain pages) +const builderProfiles = fs.readdirSync(buildersDir, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name) + .filter(isValidBuilderProfile); + +console.log(`Found ${builderProfiles.length} builder profiles.`); + +// Check if API route file exists +if (!fs.existsSync(apiRoutePath)) { + console.error('API route file not found.'); + process.exit(1); +} + +// Read the API route file +let routeContent = fs.readFileSync(apiRoutePath, 'utf8'); + +// Replace the BUILDER_PROFILES array content +routeContent = routeContent.replace( + /const BUILDER_PROFILES = \[([\s\S]*?)\];/, + `const BUILDER_PROFILES = [\n ${builderProfiles.map(profile => `"${profile}"`).join(",\n ")},\n];` +); + +// Write the updated content back to the file +fs.writeFileSync(apiRoutePath, routeContent); + +console.log('Builder profiles API route updated successfully.'); \ No newline at end of file