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