diff --git a/app/(home)/showcase/[id]/page.tsx b/app/(home)/showcase/[id]/page.tsx index 1ebe870db3b..a9bab0fb855 100644 --- a/app/(home)/showcase/[id]/page.tsx +++ b/app/(home)/showcase/[id]/page.tsx @@ -1,9 +1,8 @@ import React from "react"; import { redirect } from "next/navigation"; -import ProjectOverview from "../../../../components/showcase/ProjectOverview"; import { getProject } from "@/server/services/projects"; -import { Project } from "@/types/showcase"; import { getUserBadgesByProjectId } from "@/server/services/project-badge"; +import { ShowcaseProjectAuthWrapper } from "@/components/showcase/ShowcaseProjectAuthWrapper"; import { getAuthSession } from "@/lib/auth/authSession"; export default async function ProjectPage({ @@ -50,8 +49,10 @@ export default async function ProjectPage({ const badges = await getUserBadgesByProjectId(id); return ( -
- -
+ ); } diff --git a/app/(home)/student-launchpad/page.tsx b/app/(home)/student-launchpad/page.tsx index 1aad0f85ec6..1a73c771efb 100644 --- a/app/(home)/student-launchpad/page.tsx +++ b/app/(home)/student-launchpad/page.tsx @@ -6,27 +6,26 @@ import { useTheme } from 'next-themes' import Link from 'next/link' import { useState, useEffect } from 'react' import { useSession } from 'next-auth/react' -import { useRouter } from 'next/navigation' +import { useLoginModalTrigger } from '@/hooks/useLoginModal' export default function StudentLaunchpadPage() { const { resolvedTheme } = useTheme() const arrowColor = resolvedTheme === "dark" ? "white" : "black" const [iframeLoaded, setIframeLoaded] = useState(false) const { data: session, status } = useSession() - const router = useRouter() + const { openLoginModal } = useLoginModalTrigger() const handleIframeLoad = () => { setIframeLoaded(true) } - // Redirect to login if not authenticated + // Show login modal if not authenticated useEffect(() => { if (status === 'unauthenticated') { const currentUrl = window.location.href - const loginUrl = `/login?callbackUrl=${encodeURIComponent(currentUrl)}` - router.push(loginUrl) + openLoginModal(currentUrl) } - }, [status, router]) + }, [status, openLoginModal]) // Show loading state while checking authentication if (status === 'loading') { diff --git a/app/api/profile/[id]/route.ts b/app/api/profile/[id]/route.ts index cb7fc26cc35..7fd1ae578df 100644 --- a/app/api/profile/[id]/route.ts +++ b/app/api/profile/[id]/route.ts @@ -1,12 +1,13 @@ import { NextRequest, NextResponse } from 'next/server'; +import { Session } from 'next-auth'; import { getProfile, updateProfile } from '@/server/services/profile'; import { Profile } from '@/types/profile'; -import { withAuth } from '@/lib/protectedRoute'; +import { withAuth, RouteParams } from '@/lib/protectedRoute'; -export const GET = withAuth(async ( +export const GET = withAuth>(async ( req: NextRequest, - { params }: { params: Promise<{ id: string }> }, - session: any + { params }, + session: Session ) => { try { const id = (await params).id; @@ -36,10 +37,10 @@ export const GET = withAuth(async ( } }); -export const PUT = withAuth(async ( +export const PUT = withAuth>(async ( req: NextRequest, - { params }: { params: Promise<{ id: string }> }, - session: any + { params }, + session: Session ) => { try { const id = (await params).id; diff --git a/app/api/profile/extended/[id]/route.ts b/app/api/profile/extended/[id]/route.ts index 5180e413854..eac8985901e 100644 --- a/app/api/profile/extended/[id]/route.ts +++ b/app/api/profile/extended/[id]/route.ts @@ -1,20 +1,21 @@ import { NextRequest, NextResponse } from 'next/server'; -import { - getExtendedProfile, +import { Session } from 'next-auth'; +import { + getExtendedProfile, updateExtendedProfile, ProfileValidationError } from '@/server/services/profile/profile.service'; import { UpdateExtendedProfileData } from '@/types/extended-profile'; -import { withAuth } from '@/lib/protectedRoute'; +import { withAuth, RouteParams } from '@/lib/protectedRoute'; /** * GET /api/profile/extended/[id] * Gets the extended profile of a user */ -export const GET = withAuth(async ( +export const GET = withAuth>(async ( req: NextRequest, - { params }: { params: Promise<{ id: string }> }, - session: any + { params }, + session: Session ) => { try { const id = (await params).id; @@ -62,10 +63,10 @@ export const GET = withAuth(async ( * PUT /api/profile/extended/[id] * update extended profile */ -export const PUT = withAuth(async ( +export const PUT = withAuth>(async ( req: NextRequest, - { params }: { params: Promise<{ id: string }> }, - session: any + { params }, + session: Session ) => { try { const id = (await params).id; @@ -113,3 +114,57 @@ export const PUT = withAuth(async ( } }); +/** + * PATCH /api/profile/extended/[id] + * Partial update of extended profile (useful for settings updates) + */ +export const PATCH = withAuth>(async ( + req: NextRequest, + { params }, + session: Session +) => { + try { + const id = (await params).id; + + if (!id) { + return NextResponse.json( + { error: 'User ID is required.' }, + { status: 400 } + ); + } + + // verify that the user can only update their own profile + if (session.user.id !== id) { + return NextResponse.json( + { error: 'Forbidden: You can only update your own profile.' }, + { status: 403 } + ); + } + + const newProfileData = (await req.json()) as UpdateExtendedProfileData; + + // The service now handles all business validations + const updatedProfile = await updateExtendedProfile(id, newProfileData); + + return NextResponse.json(updatedProfile); + } catch (error) { + console.error('Error in PATCH /api/profile/extended/[id]:', error); + + // Handle validation errors with the appropriate status code + if (error instanceof ProfileValidationError) { + return NextResponse.json( + { error: error.message }, + { status: error.statusCode } + ); + } + + // Handle other errors + return NextResponse.json( + { + error: 'Internal Server Error', + details: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ); + } +}); diff --git a/app/api/profile/popular-skills/route.ts b/app/api/profile/popular-skills/route.ts index 246f004a48c..3aef373153c 100644 --- a/app/api/profile/popular-skills/route.ts +++ b/app/api/profile/popular-skills/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; +import { Session } from 'next-auth'; import { getPopularSkills } from '@/server/services/profile/profile.service'; import { withAuth } from '@/lib/protectedRoute'; - -export const GET = withAuth(async (req: NextRequest, session: any) => { +export const GET = withAuth(async (req: NextRequest, _context: unknown, session: Session) => { try { const popularSkills = await getPopularSkills(); return NextResponse.json(popularSkills, { diff --git a/app/api/profile/reward-board/route.ts b/app/api/profile/reward-board/route.ts index d6ab5a6dad7..475a432579b 100644 --- a/app/api/profile/reward-board/route.ts +++ b/app/api/profile/reward-board/route.ts @@ -1,8 +1,9 @@ +import { Session } from 'next-auth'; import { withAuth } from "@/lib/protectedRoute"; import { getRewardBoard } from "@/server/services/rewardBoard"; import { NextResponse } from "next/server"; -export const GET = withAuth(async (request, context, session) => { +export const GET = withAuth(async (request, _context: unknown, session: Session) => { const { searchParams } = new URL(request.url); const user_id = searchParams.get("user_id"); if (!user_id) { diff --git a/app/api/project/[project_id]/members/route.ts b/app/api/project/[project_id]/members/route.ts index c93f70cbe49..58abcec82f3 100644 --- a/app/api/project/[project_id]/members/route.ts +++ b/app/api/project/[project_id]/members/route.ts @@ -1,16 +1,16 @@ -import { withAuth } from "@/lib/protectedRoute"; +import { Session } from 'next-auth'; +import { withAuth, RouteParams } from "@/lib/protectedRoute"; import { prisma } from "@/prisma/prisma"; import { isUserProjectMember } from "@/server/services/fileValidation"; import { GetMembersByProjectId, UpdateRoleMember, } from "@/server/services/memberProject"; - import { NextResponse } from "next/server"; -export const GET = withAuth(async (request, context: any, session: any) => { +export const GET = withAuth>(async (request, { params }, session: Session) => { try { - const { project_id } = await context.params; + const { project_id } = await params; if (!project_id) { return NextResponse.json( { error: "project_id is required" }, @@ -40,11 +40,11 @@ export const GET = withAuth(async (request, context: any, session: any) => { } }); -export const PATCH = withAuth(async (request: Request, context: any, session: any) => { +export const PATCH = withAuth>(async (request: Request, { params }, session: Session) => { try { const body = await request.json(); const { member_id, role } = body; - const { project_id } = await context.params; + const { project_id } = await params; console.log("body", member_id); if (!member_id || !role) { diff --git a/app/api/project/[project_id]/members/status/route.ts b/app/api/project/[project_id]/members/status/route.ts index 568e6ea484b..90061e0ecda 100644 --- a/app/api/project/[project_id]/members/status/route.ts +++ b/app/api/project/[project_id]/members/status/route.ts @@ -1,13 +1,14 @@ -import { withAuth } from "@/lib/protectedRoute"; +import { Session } from 'next-auth'; +import { withAuth, RouteParams } from "@/lib/protectedRoute"; import { UpdateStatusMember } from "@/server/services/memberProject"; import { isUserProjectMember } from "@/server/services/fileValidation"; import { NextResponse } from "next/server"; -export const PATCH = withAuth(async (request: Request, context: any, session: any) => { +export const PATCH = withAuth>(async (request: Request, { params }, session: Session) => { try { const body = await request.json(); const { user_id, status, email, wasInOtherProject } = body; - const { project_id } = await context.params; + const { project_id } = await params; if (!project_id) { return NextResponse.json( diff --git a/app/api/project/check-invitation/route.ts b/app/api/project/check-invitation/route.ts index 5ea322fe155..0cb697b6eef 100644 --- a/app/api/project/check-invitation/route.ts +++ b/app/api/project/check-invitation/route.ts @@ -1,8 +1,9 @@ +import { Session } from 'next-auth'; import { withAuth } from "@/lib/protectedRoute"; import { NextResponse } from "next/server"; import { CheckInvitation } from "@/server/services/projects"; -export const GET = withAuth(async (request, context, session) => { +export const GET = withAuth(async (request, _context: unknown, session: Session) => { const { searchParams } = new URL(request.url); const invitationId = searchParams.get("invitation"); const user_id = searchParams.get("user_id"); diff --git a/app/api/project/invite-member/route.ts b/app/api/project/invite-member/route.ts index 761c60fbb6e..56db138387d 100644 --- a/app/api/project/invite-member/route.ts +++ b/app/api/project/invite-member/route.ts @@ -1,9 +1,10 @@ +import { Session } from 'next-auth'; import { withAuth } from "@/lib/protectedRoute"; import { generateInvitation } from "@/server/services/inviteProjectMember"; import { isUserProjectMember } from "@/server/services/fileValidation"; import { NextResponse } from "next/server"; -export const POST = withAuth(async (request, context, session) => { +export const POST = withAuth(async (request, _context: unknown, session: Session) => { try { const body = await request.json(); @@ -29,7 +30,7 @@ export const POST = withAuth(async (request, context, session) => { const result = await generateInvitation( body.hackathon_id, session.user.id, // Use session user ID - session.user.name, + session.user?.name ?? "", body.emails, body.project_id, // Pass project_id if it exists body.stage // Optional stage for Build Games invite links diff --git a/app/api/project/route.ts b/app/api/project/route.ts index d5877668569..465fa2a7ff3 100644 --- a/app/api/project/route.ts +++ b/app/api/project/route.ts @@ -1,10 +1,11 @@ +import { Session } from 'next-auth'; import { withAuth } from '@/lib/protectedRoute'; import { prisma } from '@/prisma/prisma'; import { GetProjectByHackathonAndUser } from '@/server/services/projects'; import { createProject } from '@/server/services/submitProject'; -import { NextResponse } from 'next/server'; +import { NextResponse } from 'next/server'; -export const POST = withAuth(async (request,context ,session) => { +export const POST = withAuth(async (request, _context: unknown, session: Session) => { try{ const body = await request.json(); const newProject = await createProject({ ...body, submittedBy: session.user.email }); @@ -25,7 +26,7 @@ export const POST = withAuth(async (request,context ,session) => { -export const GET = withAuth(async (request: Request, context, session) => { +export const GET = withAuth(async (request: Request, _context: unknown, session: Session) => { try { const { searchParams } = new URL(request.url); const hackaton_id = searchParams.get("hackathon_id") ?? ""; diff --git a/app/api/project/set-winner/route.ts b/app/api/project/set-winner/route.ts index af9f53d3160..475570b4980 100644 --- a/app/api/project/set-winner/route.ts +++ b/app/api/project/set-winner/route.ts @@ -1,33 +1,43 @@ -import { getAuthSession } from "@/lib/auth/authSession"; +import { Session } from 'next-auth'; import { withAuthRole } from "@/lib/protectedRoute"; import { SetWinner } from "@/server/services/set-project-winner"; import { NextRequest, NextResponse } from "next/server"; -export const PUT = withAuthRole("badge_admin", async (req: NextRequest) => { +export const PUT = withAuthRole("badge_admin", async (req: NextRequest, _context: unknown, session: Session) => { const body = await req.json(); - const session = await getAuthSession(); - const name = session?.user.name || "user"; + const name = session.user.name || "user"; try { if (!body.project_id) { return NextResponse.json( - { error: "project_id parameter is required" }, + { success: false, error: "project_id parameter is required" }, { status: 400 } ); } - if (!body.isWinner) { + if (body.isWinner === undefined) { return NextResponse.json( - { error: "IsWinner parameter is required" }, + { success: false, error: "isWinner parameter is required" }, { status: 400 } ); } - const badge = await SetWinner(body.project_id, body.isWinner, name); + + const result = await SetWinner(body.project_id, body.isWinner, name); - return NextResponse.json(badge, { status: 200 }); + return NextResponse.json(result, { status: 200 }); } catch (error) { - console.error("Error checking user by email:", error); + console.error("Error setting project winner:", error); + + // Handle known, safe errors that can be exposed to the client + if (error instanceof Error && error.message === "Project not found") { + return NextResponse.json( + { success: false, error: "Project not found" }, + { status: 404 } + ); + } + + // For all other errors, return a generic message to avoid leaking internal details return NextResponse.json( - { error: "Internal server error" }, + { success: false, error: "Failed to update project winner status" }, { status: 500 } ); } diff --git a/app/api/user/create-after-terms/route.ts b/app/api/user/create-after-terms/route.ts index 92223a2044c..aa8e2ab76c9 100644 --- a/app/api/user/create-after-terms/route.ts +++ b/app/api/user/create-after-terms/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { AuthOptions } from '@/lib/auth/authOptions'; +import { Session } from 'next-auth'; import { prisma } from '@/prisma/prisma'; import { syncUserDataToHubSpot } from '@/server/services/hubspotUserData'; +import { getDefaultNotificationMeans } from '@/lib/notificationDefaults'; +import { withAuth } from '@/lib/protectedRoute'; /** * API endpoint to create a new user after they accept terms. @@ -10,17 +11,12 @@ import { syncUserDataToHubSpot } from '@/server/services/hubspotUserData'; * created in the database yet (to avoid creating accounts for users who * don't accept terms). */ -export async function POST(req: NextRequest) { +export const POST = withAuth(async ( + req: NextRequest, + _context: unknown, + session: Session +) => { try { - const session = await getServerSession(AuthOptions); - - if (!session?.user?.email) { - return NextResponse.json( - { error: 'Unauthorized: No valid session' }, - { status: 401 } - ); - } - const email = session.user.email; // Check if user already exists (shouldn't happen, but safety check) @@ -51,7 +47,8 @@ export async function POST(req: NextRequest) { authentication_mode: 'credentials', last_login: new Date(), notifications: notifications, - }, + notification_means: getDefaultNotificationMeans(), + } }); // Sync user data to HubSpot (after terms acceptance) @@ -81,4 +78,4 @@ export async function POST(req: NextRequest) { { status: 500 } ); } -} +}); diff --git a/app/api/user/noun-avatar/generate-seed/route.ts b/app/api/user/noun-avatar/generate-seed/route.ts index 3fa5be84420..1b04f786d7d 100644 --- a/app/api/user/noun-avatar/generate-seed/route.ts +++ b/app/api/user/noun-avatar/generate-seed/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { Session } from 'next-auth'; import { withAuth } from '@/lib/protectedRoute'; import { keccak_256 } from '@noble/hashes/sha3'; import { bytesToHex } from '@noble/hashes/utils'; @@ -118,8 +119,8 @@ function generateAvatarSeed(identifier: string, random: boolean = false): Avatar export const GET = withAuth(async ( req: NextRequest, - context: any, - session: any + _context: unknown, + session: Session ) => { try { const { searchParams } = new URL(req.url); diff --git a/app/api/user/noun-avatar/route.ts b/app/api/user/noun-avatar/route.ts index ab432e04126..3a67bd83122 100644 --- a/app/api/user/noun-avatar/route.ts +++ b/app/api/user/noun-avatar/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { Session } from 'next-auth'; import { withAuth } from '@/lib/protectedRoute'; import { prisma } from '@/prisma/prisma'; @@ -22,8 +23,8 @@ interface AvatarSeed { */ export const PUT = withAuth(async ( req: NextRequest, - context: any, - session: any + _context: unknown, + session: Session ) => { try { const userId = session.user.id; @@ -100,8 +101,8 @@ export const PUT = withAuth(async ( */ export const GET = withAuth(async ( req: NextRequest, - context: any, - session: any + _context: unknown, + session: Session ) => { try { const userId = session.user.id; diff --git a/app/console/history/page.tsx b/app/console/history/page.tsx index 9c5541a6859..8c5812506ac 100644 --- a/app/console/history/page.tsx +++ b/app/console/history/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useSession } from 'next-auth/react'; +import { useLoginModalTrigger } from '@/hooks/useLoginModal'; import { useState, useMemo } from 'react'; import useConsoleNotifications from '@/hooks/useConsoleNotifications'; @@ -24,6 +25,7 @@ import { cn } from '@/lib/cn'; export default function ConsoleHistoryPage() { const { data: session, status } = useSession(); + const { openLoginModal } = useLoginModalTrigger(); const { logs: fullHistory, getExplorerUrl, loading } = useConsoleNotifications(); const [searchTerm, setSearchTerm] = useState(''); const [copiedId, setCopiedId] = useState(null); @@ -435,7 +437,10 @@ export default function ConsoleHistoryPage() { @@ -753,6 +754,7 @@ const ResourceItem = memo(function ResourceItem({ resource, index, collapsed, on const HackathonsEdit = () => { const { data: session, status } = useSession(); + const { openLoginModal } = useLoginModalTrigger(); const [myHackathons, setMyHackathons] = useState([]); const [loadingHackathons, setLoadingHackathons] = useState(true); const [isSelectedHackathon, setIsSelectedHackathon] = useState(false); @@ -1627,12 +1629,13 @@ const HackathonsEdit = () => { session.user.custom_attributes.includes("devrel"); }; - // Redirect unauthorized users + // Show login modal for unauthorized users React.useEffect(() => { if (status === "loading") return; // Still loading if (status === "unauthenticated") { - window.location.href = "/login"; + const currentUrl = window.location.href; + openLoginModal(currentUrl); return; } @@ -1640,7 +1643,7 @@ const HackathonsEdit = () => { window.location.href = "/"; return; } - }, [session, status]); + }, [session, status, openLoginModal]); // Show loading while checking authentication if (status === "loading") { diff --git a/app/layout.tsx b/app/layout.tsx index 362801ae48c..8f78b244df7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -13,6 +13,7 @@ import { Body } from "./layout.client"; import { HideOnChatPage } from "@/components/layout/chat-page-hider"; import { EmbedModeDetector } from "@/components/layout/embed-mode-detector"; import { ThemeProvider } from "@/components/content-design/theme-observer"; +import { UserAvatarProvider } from "@/components/context/UserAvatarContext"; export const metadata = createMetadata({ title: { @@ -46,7 +47,9 @@ export default function Layout({ children }: { children: ReactNode }) { - {children} + + {children} +
diff --git a/components/context/UserAvatarContext.tsx b/components/context/UserAvatarContext.tsx new file mode 100644 index 00000000000..0da1b6a4a89 --- /dev/null +++ b/components/context/UserAvatarContext.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { + createContext, + useCallback, + useContext, + useState, + type ReactNode, +} from 'react'; +import type { AvatarSeed } from '@/components/profile/components/DiceBearAvatar'; + +interface UserAvatarContextValue { + nounAvatarSeed: AvatarSeed | null; + nounAvatarEnabled: boolean; + setNounAvatar: (seed: AvatarSeed | null, enabled: boolean) => void; +} + +const UserAvatarContext = createContext(null); + +export function UserAvatarProvider({ children }: { children: ReactNode }) { + const [nounAvatarSeed, setNounAvatarSeed] = useState(null); + const [nounAvatarEnabled, setNounAvatarEnabled] = useState(false); + + const setNounAvatar = useCallback((seed: AvatarSeed | null, enabled: boolean) => { + setNounAvatarSeed(seed); + setNounAvatarEnabled(enabled); + }, []); + + return ( + + {children} + + ); +} + +export function useUserAvatar() { + const ctx = useContext(UserAvatarContext); + return ctx; +} diff --git a/components/hackathons/hackathon/SubmitButton.tsx b/components/hackathons/hackathon/SubmitButton.tsx index 7c6dccd09ed..530dddee8aa 100644 --- a/components/hackathons/hackathon/SubmitButton.tsx +++ b/components/hackathons/hackathon/SubmitButton.tsx @@ -4,7 +4,7 @@ import React from "react"; import { Button } from "@/components/ui/button"; import Link from "next/link"; import { useSession } from "next-auth/react"; -import { useRouter } from "next/navigation"; +import { useLoginModalTrigger } from "@/hooks/useLoginModal"; interface SubmitButtonProps { hackathonId: string; @@ -20,15 +20,15 @@ export default function SubmitButton({ variant = "red", }: SubmitButtonProps) { const { status } = useSession(); - const router = useRouter(); + const { openLoginModal } = useLoginModalTrigger(); const handleClick = (e: React.MouseEvent) => { if (status === "unauthenticated") { e.preventDefault(); - // Redirect to login with current hackathon page + #submission anchor as callback + // Show login modal with current hackathon page + #submission anchor as callback const currentPage = window.location.pathname + window.location.search; const callbackWithAnchor = `${currentPage}#submission`; - router.push(`/login?callbackUrl=${encodeURIComponent(callbackWithAnchor)}`); + openLoginModal(callbackWithAnchor); } }; diff --git a/components/hackathons/project-submission/components/GeneralSecure.tsx b/components/hackathons/project-submission/components/GeneralSecure.tsx index 0f4bcf37dc0..fc80e32c366 100644 --- a/components/hackathons/project-submission/components/GeneralSecure.tsx +++ b/components/hackathons/project-submission/components/GeneralSecure.tsx @@ -268,7 +268,7 @@ export default function GeneralSecureComponent({ {/* Header */}
-

+

{hackathon?.title ? `Submit Your Project - ${hackathon.title}` : "Create New Project"}

diff --git a/components/hackathons/project-submission/components/SubmissionStep1.tsx b/components/hackathons/project-submission/components/SubmissionStep1.tsx index 14ddc526350..b80f94f734c 100644 --- a/components/hackathons/project-submission/components/SubmissionStep1.tsx +++ b/components/hackathons/project-submission/components/SubmissionStep1.tsx @@ -240,6 +240,158 @@ const SubmitStep1: FC = (project) => { /> )} + {/* Website (Only for projects without hackathon) - key-value like Deployed Addresses */} + {!hasHackathon && ( + ( + + 0} + /> +

+ {(field.value && field.value.length > 0) ? ( +
+ {field.value.map((item: { key: string; value: string }, index: number) => ( +
+
+ { + const newItems = [...(field.value || [])]; + newItems[index] = { ...newItems[index], key: e.target.value }; + field.onChange(newItems); + }} + className='w-full dark:bg-zinc-950' + /> +
+
+ { + const newItems = [...(field.value || [])]; + newItems[index] = { ...newItems[index], value: e.target.value }; + field.onChange(newItems); + }} + className='w-full dark:bg-zinc-950' + /> +
+ +
+ ))} +
+ ) : null} + + +
+ + + )} + /> + )} + + {/* Socials (Only for projects without hackathon) - key-value like Deployed Addresses */} + {!hasHackathon && ( + ( + + 0} + /> +
+ {(field.value && field.value.length > 0) ? ( +
+ {field.value.map((item: { key: string; value: string }, index: number) => ( +
+
+ { + const newItems = [...(field.value || [])]; + newItems[index] = { ...newItems[index], key: e.target.value }; + field.onChange(newItems); + }} + className='w-full dark:bg-zinc-950' + /> +
+
+ { + const newItems = [...(field.value || [])]; + newItems[index] = { ...newItems[index], value: e.target.value }; + field.onChange(newItems); + }} + className='w-full dark:bg-zinc-950' + /> +
+ +
+ ))} +
+ ) : null} + + +
+ +
+ )} + /> + )} + {/* Deployed Addresses (Only for projects without hackathon) */} {!hasHackathon && ( { + if (!val) return []; + if (typeof val === 'string') { + try { + const parsed = JSON.parse(val); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return Object.entries(parsed).map(([key, value]) => ({ + key: String(key), + value: String(value ?? ''), + })); + } + } catch { + // single URL as string: treat as key "url" + const trimmed = val.trim(); + if (trimmed) return [{ key: 'url', value: trimmed }]; + } + return []; + } + if (typeof val === 'object' && val !== null && !Array.isArray(val)) { + return Object.entries(val).map(([k, v]) => ({ + key: String(k), + value: String(v ?? ''), + })); + } + if (Array.isArray(val)) { + return val.map((item) => + typeof item === 'object' && item !== null && 'key' in item && 'value' in item + ? { key: String(item.key), value: String(item.value ?? '') } + : { key: '', value: '' } + ); + } + return []; + }, + z + .array(z.object({ key: z.string(), value: z.string() })) + .default([]) + ), + socials: z.preprocess( + (val) => { + if (!val) return []; + if (typeof val === 'string') { + try { + const parsed = JSON.parse(val); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return Object.entries(parsed).map(([key, value]) => ({ + key: String(key), + value: String(value ?? ''), + })); + } + } catch { + return []; + } + return []; + } + if (typeof val === 'object' && val !== null && !Array.isArray(val)) { + return Object.entries(val).map(([k, v]) => ({ + key: String(k), + value: String(v ?? ''), + })); + } + if (Array.isArray(val)) { + return val.map((item) => + typeof item === 'object' && item !== null && 'key' in item && 'value' in item + ? { key: String(item.key), value: String(item.value ?? '') } + : { key: '', value: '' } + ); + } + return []; + }, + z + .array(z.object({ key: z.string(), value: z.string() })) + .default([]) + ), logo_url: z.string().optional(), cover_url: z.string().optional(), hackaton_id: z.string().optional(), @@ -300,6 +374,8 @@ export const useSubmissionFormSecure = () => { categories: [], other_category: '', deployed_addresses: [], + website: [], + socials: [], is_preexisting_idea: false, github_repository: [], demo_link: [], @@ -538,6 +614,17 @@ export const useSubmissionFormSecure = () => { (!item.tag || item.tag.trim().length > 0) ); + // Convertir website y socials (array clave-valor) a objeto JSONB + const keyValueToObject = (arr: Array<{ key: string; value: string }> | undefined) => { + if (!arr || !Array.isArray(arr)) return null; + const obj: Record = {}; + arr.forEach(({ key, value }) => { + const k = key?.trim(); + if (k && value != null) obj[k] = String(value).trim(); + }); + return Object.keys(obj).length > 0 ? obj : null; + }; + const finalData = { ...data, logo_url: uploadedFiles.logoFileUrl ?? '', @@ -547,6 +634,8 @@ export const useSubmissionFormSecure = () => { demo_link: data.demo_link?.join(',') ?? "", categories: data.categories?.join(',') ?? "", deployed_addresses: filteredDeployedAddresses, + website: keyValueToObject(data.website), + socials: keyValueToObject(data.socials), is_winner: false, ...(state.hackathonId && { hackaton_id: state.hackathonId }), user_id: session?.user?.id, @@ -653,6 +742,42 @@ export const useSubmissionFormSecure = () => { deployed_addresses: Array.isArray(project.deployed_addresses) ? project.deployed_addresses : [], + website: (() => { + const w = project.website; + if (!w) return []; + if (typeof w === 'object' && w !== null && !Array.isArray(w)) { + return Object.entries(w).map(([key, value]) => ({ key, value: String(value ?? '') })); + } + if (typeof w === 'string') { + try { + const p = JSON.parse(w); + if (p && typeof p === 'object' && !Array.isArray(p)) { + return Object.entries(p).map(([k, v]) => ({ key: k, value: String(v ?? '') })); + } + } catch { + return []; + } + } + return Array.isArray(w) ? w : []; + })(), + socials: (() => { + const s = project.socials; + if (!s) return []; + if (typeof s === 'object' && s !== null && !Array.isArray(s)) { + return Object.entries(s).map(([key, value]) => ({ key, value: String(value ?? '') })); + } + if (typeof s === 'string') { + try { + const p = JSON.parse(s); + if (p && typeof p === 'object' && !Array.isArray(p)) { + return Object.entries(p).map(([k, v]) => ({ key: k, value: String(v ?? '') })); + } + } catch { + return []; + } + } + return Array.isArray(s) ? s : []; + })(), logoFile: project.logo_url ?? undefined, coverFile: project.cover_url ?? undefined, screenshots: project.screenshots ?? [], diff --git a/components/hackathons/registration-form/RegisterFormStep1.tsx b/components/hackathons/registration-form/RegisterFormStep1.tsx index fbe3f72f617..2bfc4a8c419 100644 --- a/components/hackathons/registration-form/RegisterFormStep1.tsx +++ b/components/hackathons/registration-form/RegisterFormStep1.tsx @@ -76,7 +76,8 @@ export default function RegisterFormStep1({ user }: Step1Props) { type="email" placeholder="your@email.com" {...field} - className="bg-transparent placeholder-zinc-600" + readOnly + className="bg-transparent placeholder-zinc-600 cursor-default opacity-90" /> diff --git a/components/hackathons/registration-form/RegistrationForm.tsx b/components/hackathons/registration-form/RegistrationForm.tsx index a63c40a1693..8275b87cbec 100644 --- a/components/hackathons/registration-form/RegistrationForm.tsx +++ b/components/hackathons/registration-form/RegistrationForm.tsx @@ -31,7 +31,7 @@ import { useUTMPreservation } from "@/hooks/use-utm-preservation"; // Esquema de validación const createRegisterSchema = (isOnline: boolean) => z.object({ - name: z.string().min(1, "Name is required"), + name: z.string().trim().min(1, "Name is required"), email: z.string().email("Invalid email"), company_name: z.string().optional(), telegram_user: z.string().min(1, "Telegram username is required"), @@ -137,6 +137,62 @@ export function RegisterForm({ } } + /** Prefill step1 from profile (name, email, country, telegram, company, role) when field is empty */ + async function mergeProfileIntoStep1() { + const userId = (currentUser as { id?: string })?.id; + if (!userId) return; + try { + const profileRes = await fetch(`/api/profile/extended/${userId}`); + if (!profileRes.ok) return; + const profile = await profileRes.json(); + const current = form.getValues(); + const merged = { + ...current, + name: (current.name ?? "").trim() || profile.name || current.name || "", + email: (current.email ?? "").trim() || profile.email || current.email || "", + city: (current.city ?? "").trim() || profile.country || current.city || "", + telegram_user: (current.telegram_user ?? "").trim() || profile.telegram_user || current.telegram_user || "", + company_name: (current.company_name ?? "").trim() || profile.user_type?.company_name || profile.user_type?.founder_company_name || profile.user_type?.employee_company_name || profile.user_type?.student_institution || current.company_name || "", + role: (current.role ?? "").trim() || profile.user_type?.employee_role || profile.user_type?.role || current.role || "", + }; + form.reset(merged); + } catch (err) { + console.error("Error merging profile into registration form:", err); + } + } + + /** Persist step1 fields to profile (name, email, country, telegram, company_name, role) */ + async function saveStep1ToProfile() { + const userId = (currentUser as { id?: string })?.id; + if (!userId) return; + const step1 = form.getValues(); + try { + const profileRes = await fetch(`/api/profile/extended/${userId}`); + if (!profileRes.ok) return; + const existing = await profileRes.json(); + const userType = existing.user_type || {}; + const payload = { + name: step1.name ?? existing.name, + email: step1.email ?? existing.email, + country: (step1.city ?? "").trim() || existing.country, + telegram_user: (step1.telegram_user ?? "").trim() || existing.telegram_user, + user_type: { + ...userType, + company_name: (step1.company_name ?? "").trim() || userType.company_name, + role: (step1.role ?? "").trim() || userType.role, + employee_role: (step1.role ?? "").trim() || userType.employee_role, + }, + }; + await fetch(`/api/profile/extended/${userId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + } catch (err) { + console.error("Error saving step1 to profile:", err); + } + } + async function getRegisterFormLoaded() { if (!hackathon_id || !currentUser?.email) return; try { @@ -175,8 +231,12 @@ export function RegisterForm({ setRegistrationForm(loadedData); } setDataFromLocalStorage(); + await mergeProfileIntoStep1(); } catch (err) { setDataFromLocalStorage(); + if (status === "authenticated" && currentUser) { + await mergeProfileIntoStep1(); + } console.error("API Error:", err); } } @@ -238,7 +298,10 @@ export function RegisterForm({ } }, [hackathon, form]); - const onSaveLater = () => { + const onSaveLater = async () => { + if (step === 1) { + await saveStep1ToProfile(); + } const preservedUTMs = getPreservedUTMs(); const effectiveUTM = utm || preservedUTMs.utm || ""; @@ -318,8 +381,11 @@ export function RegisterForm({ } }; - const handleStepChange = (newStep: number) => { + const handleStepChange = async (newStep: number) => { if (newStep >= 1 && newStep <= 3) { + if (step === 1 && newStep !== 1) { + await saveStep1ToProfile(); + } setStep(newStep); } }; @@ -419,6 +485,9 @@ export function RegisterForm({ } const isValid = await form.trigger(fieldsToValidate); if (isValid) { + if (step === 1) { + await saveStep1ToProfile(); + } setStep((prev) => prev + 1); } }; diff --git a/components/login/BasicProfileSetup.tsx b/components/login/BasicProfileSetup.tsx index ff2636435ec..0d3da233637 100644 --- a/components/login/BasicProfileSetup.tsx +++ b/components/login/BasicProfileSetup.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; +import { zodResolver } from '@/lib/zodResolver'; import * as z from 'zod'; import axios from 'axios'; import { useRouter } from 'next/navigation'; @@ -60,6 +60,7 @@ export function BasicProfileSetup({ userId, onSuccess, onCompleteProfile }: Basi const form = useForm({ resolver: zodResolver(basicProfileSchema), + mode: 'onChange', defaultValues: { name: '', country: '', diff --git a/components/login/LoginModal.tsx b/components/login/LoginModal.tsx index eb2106e03e3..215a063fa29 100644 --- a/components/login/LoginModal.tsx +++ b/components/login/LoginModal.tsx @@ -66,12 +66,12 @@ export function LoginModal() { {/* Compact Header - Full Width */} -
-
+
+
@@ -271,7 +271,7 @@ export function LoginModalWrapper() { diff --git a/components/login/social-login/SocialLogin.tsx b/components/login/social-login/SocialLogin.tsx index f8f33926ebf..4d80e640a62 100644 --- a/components/login/social-login/SocialLogin.tsx +++ b/components/login/social-login/SocialLogin.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; import { SocialLoginProps } from "@/types/socialLoginProps"; function SocialLogin({ callbackUrl = "/" }: SocialLoginProps) { - async function SignInSocialMedia(provider: "google" | "github" | "X") { + async function SignInSocialMedia(provider: "google" | "github" | "twitter") { await signIn(provider, { callbackUrl: callbackUrl }); } @@ -17,7 +17,7 @@ function SocialLogin({ callbackUrl = "/" }: SocialLoginProps) {
- + Or
@@ -56,16 +56,16 @@ function SocialLogin({ callbackUrl = "/" }: SocialLoginProps) {
diff --git a/components/login/user-button/UserButton.tsx b/components/login/user-button/UserButton.tsx index 8896c330a3a..b3007526aa6 100644 --- a/components/login/user-button/UserButton.tsx +++ b/components/login/user-button/UserButton.tsx @@ -15,25 +15,56 @@ import { useState, useMemo, useEffect } from 'react'; import { CircleUserRound } from 'lucide-react'; import { Separator } from '@radix-ui/react-dropdown-menu'; import { useLoginModalTrigger } from '@/hooks/useLoginModal'; +import { DiceBearAvatar } from '@/components/profile/components/DiceBearAvatar'; +import type { AvatarSeed } from '@/components/profile/components/DiceBearAvatar'; +import { useUserAvatar } from '@/components/context/UserAvatarContext'; + +const AVATAR_SIZE = 32; + export function UserButton() { const { data: session, status } = useSession() ?? {}; const [isDialogOpen, setIsDialogOpen] = useState(false); + const [localSeed, setLocalSeed] = useState(null); + const [localEnabled, setLocalEnabled] = useState(false); + const avatarContext = useUserAvatar(); const isAuthenticated = status === 'authenticated'; const { openLoginModal } = useLoginModalTrigger(); - // Dividir el correo por @ para evitar cortes no deseados - const formattedEmail = useMemo(() => { - const email = session?.user?.email; - if (!email) return null; - - const atIndex = email.indexOf('@'); - if (atIndex === -1) return { localPart: email, domain: null }; + const nounAvatarSeed = avatarContext?.nounAvatarSeed ?? localSeed; + const nounAvatarEnabled = avatarContext?.nounAvatarEnabled ?? localEnabled; - return { - localPart: email.substring(0, atIndex), - domain: email.substring(atIndex), // Incluye el @ + // Sincronizar avatar con API; actualizar contexto (si existe) o estado local + useEffect(() => { + if (!isAuthenticated) { + avatarContext?.setNounAvatar(null, false); + setLocalSeed(null); + setLocalEnabled(false); + return; + } + let cancelled = false; + fetch('/api/user/noun-avatar') + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!cancelled && data) { + const seed = data.seed ?? null; + const enabled = data.enabled ?? false; + avatarContext?.setNounAvatar(seed, enabled); + setLocalSeed(seed); + setLocalEnabled(enabled); + } + }) + .catch(() => { + if (!cancelled) { + avatarContext?.setNounAvatar(null, false); + setLocalSeed(null); + setLocalEnabled(false); + } + }); + return () => { + cancelled = true; }; - }, [session?.user?.email]); + }, [isAuthenticated, avatarContext?.setNounAvatar]); + const handleSignOut = (): void => { // Clean up any stored redirect URLs before logout if (typeof window !== "undefined") { @@ -71,14 +102,22 @@ export function UserButton() {
{session.user.name && session.user.name !== session.user.email && ( -

+

{session.user.name}

)} @@ -157,7 +196,7 @@ export function UserButton() { }} > diff --git a/components/profile/components/DiceBearAvatar.tsx b/components/profile/components/DiceBearAvatar.tsx index a3748302326..324a4eea884 100644 --- a/components/profile/components/DiceBearAvatar.tsx +++ b/components/profile/components/DiceBearAvatar.tsx @@ -130,15 +130,15 @@ export function DiceBearAvatar({ const [svgDataUri, setSvgDataUri] = useState(null); const [isLoading, setIsLoading] = useState(true); - // Size configurations + // Size configurations (container/SVG must be larger than avatar so progress ring wraps around) const sizeConfig = { small: { - container: "h-10 w-10", + container: "h-14 w-14", avatar: "h-12 w-12", textSize: "text-lg", - svg: 40, - center: 20, - radius: 18, + svg: 56, + center: 28, + radius: 25, }, large: { container: "h-40 w-40", @@ -162,27 +162,27 @@ export function DiceBearAvatar({ const circumference = 2 * Math.PI * config.radius; const offset = circumference - (profileProgress / 100) * circumference; - // Get progress color based on percentage + // Get progress color: red ≤50%, orange ≤85%, green >85% const getProgressColor = () => { - if (profileProgress < 40) { + if (profileProgress <= 50) { return { gradientStart: "#ef4444", gradientEnd: "#dc2626", shadowColor: "rgba(239, 68, 68, 0.3)", }; - } else if (profileProgress <= 80) { - return { - gradientStart: "#FCD34D", - gradientEnd: "#FBBF24", - shadowColor: "rgba(252, 211, 77, 0.3)", - }; - } else { + } + if (profileProgress <= 85) { return { - gradientStart: "#4D7C0F", - gradientEnd: "#65A30D", - shadowColor: "rgba(77, 124, 15, 0.3)", + gradientStart: "#f97316", + gradientEnd: "#ea580c", + shadowColor: "rgba(249, 115, 22, 0.3)", }; } + return { + gradientStart: "#22c55e", + gradientEnd: "#16a34a", + shadowColor: "rgba(34, 197, 94, 0.3)", + }; }; const progressColor = getProgressColor(); diff --git a/components/profile/components/ProfileHeader.tsx b/components/profile/components/ProfileHeader.tsx index ef3156a14c0..a70873e4503 100644 --- a/components/profile/components/ProfileHeader.tsx +++ b/components/profile/components/ProfileHeader.tsx @@ -14,6 +14,8 @@ interface ProfileHeaderProps { onEditName?: () => void; nounAvatarSeed?: AvatarSeed | null; nounAvatarEnabled?: boolean; + /** 0–100; drives the circular progress bar around the avatar. */ + completionPercentage?: number; } export function ProfileHeader({ @@ -26,6 +28,7 @@ export function ProfileHeader({ onEditName, nounAvatarSeed, nounAvatarEnabled = false, + completionPercentage = 0, }: ProfileHeaderProps) { const [isHoveringAvatar, setIsHoveringAvatar] = useState(false); const [isHoveringName, setIsHoveringName] = useState(false); @@ -34,9 +37,9 @@ export function ProfileHeader({
{/* Avatar and Name Section - Horizontal Layout */}
- {/* Small Avatar */} + {/* Small Avatar: container h-14 w-14 (avatar circle is h-12 w-12) */}
setIsHoveringAvatar(true)} onMouseLeave={() => setIsHoveringAvatar(false)} onClick={onEditAvatar} @@ -46,19 +49,28 @@ export function ProfileHeader({ seed={nounAvatarSeed} name={name} size="small" - showProgress={false} + showProgress={true} + profileProgress={completionPercentage} /> ) : ( )} {isHoveringAvatar && ( -
- +
+
+ +
)}
@@ -70,13 +82,13 @@ export function ProfileHeader({ onMouseEnter={() => setIsHoveringName(true)} onMouseLeave={() => setIsHoveringName(false)} > -

+

{name || "Your Name"}

{onEditName && (
{/* Email below name - split before @ for better wrapping */} {email && ( -

+

{email.includes('@') ? ( <> {email.split('@')[0]} - @{email.split('@')[1]} + @{email.split('@')[1]} ) : ( email diff --git a/components/profile/components/WalletConnectButton.tsx b/components/profile/components/WalletConnectButton.tsx index dd4e765db39..d1dd542cfe8 100644 --- a/components/profile/components/WalletConnectButton.tsx +++ b/components/profile/components/WalletConnectButton.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -12,7 +12,6 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { Wallet, QrCode, Loader2 } from "lucide-react"; -import { useToast } from "@/hooks/use-toast"; import EthereumProvider from "@walletconnect/ethereum-provider"; import { QRCodeSVG } from "qrcode.react"; @@ -38,9 +37,44 @@ interface EIP6963ProviderInfo { rdns: string; } +// Common interface for Ethereum providers that support the request method (EIP-1193) +// Using generic type parameter to allow type-safe return values +interface EthereumProviderRequest { + request(args: { method: string; params?: unknown[] }): Promise; +} + interface EIP6963ProviderDetail { info: EIP6963ProviderInfo; - provider: any; + provider: EthereumProviderRequest; +} + +// Local type for window with wallet provider properties (avoids modifying global types) +// Not extending Window to prevent conflicts with global ethereum type; used only for assertion. +interface WindowWithWalletProviders { + ethereum?: EthereumProviderRequest & { + isMetaMask?: boolean; + isBraveWallet?: boolean; + isRainbow?: boolean; + on?(event: string, callback: (...args: unknown[]) => void): void; + removeListener?(event: string, callback: (...args: unknown[]) => void): void; + }; + coinbaseWalletExtension?: EthereumProviderRequest; + // Core Wallet (Avalanche wallet) - uses window.avalanche + // Use the same type as global.d.ts for avalanche to maintain compatibility + avalanche?: { + request: (args: { + method: string; + params?: Record | unknown[]; + id?: number; + }) => Promise; + on?: (event: string, callback: (data: T) => void) => void; + removeListener?: (event: string, callback: () => void) => void; + }; + zerion?: EthereumProviderRequest; +} + +function getWalletWindow(): WindowWithWalletProviders { + return typeof window === "undefined" ? ({} as WindowWithWalletProviders) : (window as unknown as WindowWithWalletProviders); } // Known wallet identifiers and their metadata @@ -89,7 +123,6 @@ export function WalletConnectButton({ const [qrCodeUri, setQrCodeUri] = useState(null); const [showQRCode, setShowQRCode] = useState(false); const [eip6963Providers, setEip6963Providers] = useState([]); - const { toast } = useToast(); // Use useRef to maintain stable callback reference const onWalletConnectedRef = useRef(onWalletConnected); @@ -99,6 +132,13 @@ export function WalletConnectButton({ onWalletConnectedRef.current = onWalletConnected; }, [onWalletConnected]); + // Request accounts; wallet_revokePermissions was tried but Zerion returns 403, so we call eth_requestAccounts only + // eth_requestAccounts returns string[] according to EIP-1193 + const requestAccountsWithPicker = useCallback(async (provider: EthereumProviderRequest): Promise => { + const accounts = await provider.request({ method: "eth_requestAccounts" }); + return Array.isArray(accounts) ? accounts.filter((account): account is string => typeof account === "string") : []; + }, []); + // Initialize WalletConnect Provider with singleton pattern useEffect(() => { if (typeof window === "undefined") return; @@ -160,10 +200,6 @@ export function WalletConnectButton({ setIsOpen(false); setShowQRCode(false); setQrCodeUri(null); - toast({ - title: "Wallet Connected", - description: "Successfully connected via WalletConnect", - }); } }); @@ -303,9 +339,7 @@ export function WalletConnectButton({ type: "extension", connect: async () => { try { - const accounts = await provider.request({ - method: "eth_requestAccounts", - }) as string[]; + const accounts = await requestAccountsWithPicker(provider); return accounts?.[0] || null; } catch (error: any) { if (error.code === 4001) { @@ -320,13 +354,14 @@ export function WalletConnectButton({ if (info.rdns) { detectedIds.add(info.rdns); detectedIds.add(rdnsKeyNormalized); - } + } detectedIds.add(nameKey); } }); // Legacy detection: MetaMask (check isMetaMask flag first to avoid duplicates) - if ((window.ethereum as any)?.isMetaMask && !detectedIds.has("metamask")) { + const win = getWalletWindow(); + if (win.ethereum?.isMetaMask && !detectedIds.has("metamask")) { wallets.push({ name: "MetaMask", icon: "🦊", @@ -335,9 +370,7 @@ export function WalletConnectButton({ type: "extension", connect: async () => { try { - const accounts = await (window.ethereum as any)!.request({ - method: "eth_requestAccounts", - }) as string[]; + const accounts = await requestAccountsWithPicker(win.ethereum!); return accounts?.[0] || null; } catch (error: any) { if (error.code === 4001) { @@ -352,7 +385,7 @@ export function WalletConnectButton({ // Zerion Wallet detection (window.zerion) // Check both window.zerion and if it's already detected via EIP-6963 - if (window.zerion && + if (win.zerion && !detectedIds.has("zerion") && !detectedIds.has("io.zerion") && !detectedIds.has("io_zerion")) { @@ -364,9 +397,7 @@ export function WalletConnectButton({ type: "extension", connect: async () => { try { - const accounts = await window.zerion!.request({ - method: "eth_requestAccounts", - }) as string[]; + const accounts = await requestAccountsWithPicker(win.zerion!); return accounts?.[0] || null; } catch (error: any) { if (error.code === 4001) { @@ -382,7 +413,7 @@ export function WalletConnectButton({ } // Coinbase Wallet detection (window.coinbaseWalletExtension) - if ((window as any).coinbaseWalletExtension && !detectedIds.has("coinbase")) { + if (win.coinbaseWalletExtension && !detectedIds.has("coinbase")) { wallets.push({ name: "Coinbase Wallet", icon: "🔵", @@ -391,9 +422,7 @@ export function WalletConnectButton({ type: "extension", connect: async () => { try { - const accounts = await (window as any).coinbaseWalletExtension.request({ - method: "eth_requestAccounts", - }) as string[]; + const accounts = await requestAccountsWithPicker(win.coinbaseWalletExtension!); return accounts?.[0] || null; } catch (error: any) { if (error.code === 4001) { @@ -408,7 +437,7 @@ export function WalletConnectButton({ // Core Wallet (Avalanche wallet) - Always show, even if not installed if (!detectedIds.has("core")) { - const isCoreInstalled = !!(window as any).avalanche?.request; + const isCoreInstalled = !!win.avalanche?.request; wallets.push({ name: "Core Wallet", icon: "🔷", @@ -421,9 +450,7 @@ export function WalletConnectButton({ throw new Error("Please install Core Wallet extension to connect."); } try { - const accounts = await window.avalanche!.request({ - method: "eth_requestAccounts", - }) as string[]; + const accounts = await requestAccountsWithPicker(win.avalanche!); return accounts?.[0] || null; } catch (error: any) { if (error.code === 4001) { @@ -437,7 +464,7 @@ export function WalletConnectButton({ } // Brave Wallet detection - if ((window.ethereum as any)?.isBraveWallet && !detectedIds.has("brave")) { + if (win.ethereum?.isBraveWallet && !detectedIds.has("brave")) { wallets.push({ name: "Brave Wallet", icon: "🦁", @@ -446,9 +473,7 @@ export function WalletConnectButton({ type: "extension", connect: async () => { try { - const accounts = await (window.ethereum as any)!.request({ - method: "eth_requestAccounts", - }) as string[]; + const accounts = await requestAccountsWithPicker(win.ethereum!); return accounts?.[0] || null; } catch (error: any) { if (error.code === 4001) { @@ -462,7 +487,7 @@ export function WalletConnectButton({ } // Rainbow Wallet detection - if ((window.ethereum as any)?.isRainbow && !detectedIds.has("rainbow")) { + if (win.ethereum?.isRainbow && !detectedIds.has("rainbow")) { wallets.push({ name: "Rainbow", icon: "🌈", @@ -471,9 +496,7 @@ export function WalletConnectButton({ type: "extension", connect: async () => { try { - const accounts = await (window.ethereum as any)!.request({ - method: "eth_requestAccounts", - }) as string[]; + const accounts = await requestAccountsWithPicker(win.ethereum!); return accounts?.[0] || null; } catch (error: any) { if (error.code === 4001) { @@ -488,10 +511,10 @@ export function WalletConnectButton({ // Other EIP-1193 providers (fallback for unknown wallets) if ( - window.ethereum && - !(window.ethereum as any).isMetaMask && - !(window.ethereum as any).isBraveWallet && - !(window.ethereum as any).isRainbow && + win.ethereum && + !win.ethereum.isMetaMask && + !win.ethereum.isBraveWallet && + !win.ethereum.isRainbow && !detectedIds.has("other") ) { wallets.push({ @@ -502,9 +525,7 @@ export function WalletConnectButton({ type: "extension", connect: async () => { try { - const accounts = await (window.ethereum as any)!.request({ - method: "eth_requestAccounts", - }) as string[]; + const accounts = await requestAccountsWithPicker(win.ethereum!); return accounts?.[0] || null; } catch (error: any) { if (error.code === 4001) { @@ -526,24 +547,25 @@ export function WalletConnectButton({ // update available wallets when the WalletConnect provider or EIP-6963 providers change useEffect(() => { setAvailableWallets(detectWallets()); - }, [walletConnectProvider, eip6963Providers]); + }, [walletConnectProvider, eip6963Providers, requestAccountsWithPicker]); // Listen for account changes in MetaMask useEffect(() => { - if (typeof window === "undefined" || !window.ethereum || !currentAddress) { + const win = getWalletWindow(); + if (typeof window === "undefined" || !win.ethereum || !currentAddress) { return; } - const handleAccountsChanged = (accounts: string[]) => { + const handleAccountsChanged = (...args: unknown[]) => { + const accounts = Array.isArray(args[0]) ? (args[0] as string[]) : []; if (accounts.length > 0 && currentAddress) { - // Use stable reference instead of the function directly onWalletConnectedRef.current(accounts[0]); } }; - const ethereum = window.ethereum as any; + const ethereum = win.ethereum; ethereum.on?.("accountsChanged", handleAccountsChanged); - + return () => { ethereum.removeListener?.("accountsChanged", handleAccountsChanged); }; @@ -563,20 +585,11 @@ export function WalletConnectButton({ onWalletConnected(address); setIsOpen(false); setIsConnecting(false); - toast({ - title: "Wallet Connected", - description: `Successfully connected to ${wallet.name}`, - }); } } } catch (error: any) { setIsConnecting(false); setShowQRCode(false); - toast({ - title: "Connection Failed", - description: error.message || "Failed to connect wallet", - variant: "destructive", - }); } }; diff --git a/components/profile/components/hooks/useProfileForm.ts b/components/profile/components/hooks/useProfileForm.ts index 73404d89e71..d98a9df5378 100644 --- a/components/profile/components/hooks/useProfileForm.ts +++ b/components/profile/components/hooks/useProfileForm.ts @@ -1,13 +1,13 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { zodResolver } from "@/lib/zodResolver"; import { z } from "zod"; import { useSession } from "next-auth/react"; import { useToast } from "@/hooks/use-toast"; -// Zod validation schema - no required fields, only format validations +// Zod validation schema - name is required; rest are format validations export const profileSchema = z.object({ - name: z.string().optional(), + name: z.string().trim().min(1, 'Name is required'), username: z.string().optional(), bio: z.string().max(250, "Bio must not exceed 250 characters").optional(), email: z.email("Invalid email").optional(), // Email from session, optional @@ -40,6 +40,36 @@ export const profileSchema = z.object({ export type ProfileFormValues = z.infer; +/** Number of criteria used for profile completion (each counts 1). */ +const PROFILE_COMPLETION_CRITERIA = 9; + +/** + * Computes profile completion percentage (0–100) based on filled fields + * used in the profile form. Used for the circular progress around the avatar. + */ +export function getProfileCompletionPercentage(values: Partial | undefined): number { + if (!values) return 0; + const v = values; + const has = (s: string | undefined) => (s?.trim() ?? "") !== ""; + const hasRole = + v.is_developer === true || + v.is_enthusiast === true || + (v.is_student === true && has(v.student_institution)) || + (v.is_founder === true && has(v.founder_company_name)) || + (v.is_employee === true && has(v.employee_company_name) && has(v.employee_role)); + let completed = 0; + if (has(v.name)) completed++; + if (has(v.bio)) completed++; + if (has(v.country)) completed++; + if (hasRole) completed++; + if (has(v.github)) completed++; + if (Array.isArray(v.wallet) && v.wallet.filter((w) => has(w)).length > 0) completed++; + if (has(v.telegram_user)) completed++; + if (Array.isArray(v.socials) && v.socials.length > 0) completed++; + if (Array.isArray(v.skills) && v.skills.length > 0) completed++; + return Math.round((completed / PROFILE_COMPLETION_CRITERIA) * 100); +} + export function useProfileForm() { const { data: session } = useSession(); const { toast } = useToast(); @@ -54,6 +84,7 @@ export function useProfileForm() { // Initialize form with react-hook-form and Zod const form = useForm({ resolver: zodResolver(profileSchema), + mode: "onChange", defaultValues: { name: "", username: "", @@ -538,13 +569,14 @@ export function useProfileForm() { // Wallet handlers const handleAddWallet = (address: string) => { const currentWallets = watchedValues.wallet || []; - // Validar formato antes de agregar - if (address && address.trim() !== "" && /^0x[a-fA-F0-9]{40}$/.test(address.trim())) { - const trimmedAddress = address.trim(); - // Evitar duplicados - if (!currentWallets.includes(trimmedAddress)) { - setValue("wallet", [...currentWallets, trimmedAddress], { shouldDirty: true }); - } + const trimmedAddress = address?.trim() ?? ""; + if (trimmedAddress === "" || !/^0x[a-fA-F0-9]{40}$/.test(trimmedAddress)) return; + // Evitar duplicados (comparación case-insensitive: las direcciones Ethereum son la misma con distinta capitalización) + const isDuplicate = currentWallets.some( + (w) => w.toLowerCase() === trimmedAddress.toLowerCase() + ); + if (!isDuplicate) { + setValue("wallet", [...currentWallets, trimmedAddress], { shouldDirty: true }); } }; diff --git a/components/profile/components/profile-tab.tsx b/components/profile/components/profile-tab.tsx index 998b10a1e31..a5f4258c962 100644 --- a/components/profile/components/profile-tab.tsx +++ b/components/profile/components/profile-tab.tsx @@ -8,9 +8,10 @@ import { ProfileHeader } from "./ProfileHeader"; import type { ReactNode } from "react"; import { useState, useEffect } from "react"; import { useSession } from "next-auth/react"; -import { useProfileForm } from "./hooks/useProfileForm"; +import { useProfileForm, getProfileCompletionPercentage } from "./hooks/useProfileForm"; import { AvatarSeed } from "./DiceBearAvatar"; import { NounAvatarConfig } from "./NounAvatarConfig"; +import { useUserAvatar } from "@/components/context/UserAvatarContext"; // Map hash values to tab values (case-insensitive) const hashToTabMap: Record = { @@ -29,34 +30,51 @@ interface ProfileTabProps { export default function ProfileTab({ achievements }: ProfileTabProps) { const { data: session } = useSession(); + const avatarContext = useUserAvatar(); const [isNounAvatarConfigOpen, setIsNounAvatarConfigOpen] = useState(false); const [nounAvatarSeed, setNounAvatarSeed] = useState(null); const [nounAvatarEnabled, setNounAvatarEnabled] = useState(false); - // Get profile data using the hook - const { form, watchedValues, isLoading } = useProfileForm(); - - // Load Noun avatar data + // Single form instance so header progress updates while editing (watchedValues shared) + const { + form, + watchedValues, + isLoading, + isSaving, + isAutoSaving, + handleRemoveSkill, + handleAddSocial, + handleRemoveSocial, + handleAddWallet, + handleRemoveWallet, + onSubmit, + } = useProfileForm(); + + // Load Noun avatar data and sincronizar con contexto (para que UserButton lo muestre) useEffect(() => { async function loadNounAvatar() { try { const response = await fetch("/api/user/noun-avatar"); if (response.ok) { const data = await response.json(); - setNounAvatarSeed(data.seed); - setNounAvatarEnabled(data.enabled ?? false); + const seed = data.seed ?? null; + const enabled = data.enabled ?? false; + setNounAvatarSeed(seed); + setNounAvatarEnabled(enabled); + avatarContext?.setNounAvatar(seed, enabled); } } catch (error) { console.error("Error loading Noun avatar:", error); } } loadNounAvatar(); - }, []); + }, [avatarContext?.setNounAvatar]); - // Handle avatar save + // Handle avatar save: actualizar estado local y contexto para que UserButton refleje el cambio const handleNounAvatarSave = async (seed: AvatarSeed, enabled: boolean) => { setNounAvatarSeed(seed); setNounAvatarEnabled(enabled); + avatarContext?.setNounAvatar(seed, enabled); }; // Get initial tab from URL hash @@ -133,6 +151,7 @@ export default function ProfileTab({ achievements }: ProfileTabProps) { onEditAvatar={() => setIsNounAvatarConfigOpen(true)} nounAvatarSeed={nounAvatarSeed} nounAvatarEnabled={nounAvatarEnabled} + completionPercentage={getProfileCompletionPercentage(watchedValues)} /> {/* Separator */} @@ -171,7 +190,18 @@ export default function ProfileTab({ achievements }: ProfileTabProps) { {/* Right Content - Tab Content */}

- + diff --git a/components/profile/components/profile.tsx b/components/profile/components/profile.tsx index c063804b512..ac946164eac 100644 --- a/components/profile/components/profile.tsx +++ b/components/profile/components/profile.tsx @@ -27,41 +27,40 @@ import { hsEmploymentRoles } from "@/constants/hs_employment_role"; import { X, Link2, Wallet, User, FileText, Zap } from "lucide-react"; import { WalletConnectButton } from "./WalletConnectButton"; import { SkillsAutocomplete } from "./SkillsAutocomplete"; -import { useProfileForm } from "./hooks/useProfileForm"; +import type { UseFormReturn } from "react-hook-form"; +import type { ProfileFormValues } from "./hooks/useProfileForm"; import { LoadingButton } from "@/components/ui/loading-button"; import { Toaster } from "@/components/ui/toaster"; import { ProfileChecklist } from "./ProfileChecklist"; -export default function Profile() { +export interface ProfileProps { + form: UseFormReturn; + watchedValues: Partial; + isSaving: boolean; + isAutoSaving: boolean; + handleRemoveSkill: (skillToRemove: string) => void; + handleAddSocial: () => void; + handleRemoveSocial: (index: number) => void; + handleAddWallet: (address: string) => void; + handleRemoveWallet: (index: number) => void; + onSubmit: (e?: React.BaseSyntheticEvent) => Promise; +} + +export default function Profile({ + form, + watchedValues, + isSaving, + isAutoSaving, + handleRemoveSkill, + handleAddSocial, + handleRemoveSocial, + handleAddWallet, + handleRemoveWallet, + onSubmit, +}: ProfileProps) { const [newSkill, setNewSkill] = useState(""); const [newSocial, setNewSocial] = useState(""); - // Use custom hook for all profile logic - const { - form, - watchedValues, - isLoading, - isSaving, - isAutoSaving, - handleRemoveSkill, - handleAddSocial, - handleRemoveSocial, - handleAddWallet, - handleRemoveWallet, - onSubmit, - } = useProfileForm(); - - if (isLoading) { - return ( -
-
-
-

Loading profile...

-
-
- ); - } - return ( <> {/* Form Content */} @@ -140,7 +139,7 @@ export default function Profile() { name="country" render={({ field }) => ( - City of Residence + Country