From 2315a003cb89adcf281250acc4eae0e6db79fcc4 Mon Sep 17 00:00:00 2001 From: jhoan Date: Tue, 7 Oct 2025 22:16:13 -0500 Subject: [PATCH 01/12] Enhance Ambassador DAO user flow and error handling - Updated middleware to include new protected paths for Ambassador DAO profile and onboarding. - Refactored layout components to improve user redirection based on authentication status and token errors. - Added error handling for token acquisition failures, ensuring users are directed to onboarding or appropriate pages based on their status. - Introduced new onboarding option in the user menu for new users. - Updated user authentication logic to streamline user experience across Ambassador DAO components. --- app/(home)/ambassador-dao/onboard/layout.tsx | 26 ++++++- app/(home)/ambassador-dao/profile/layout.tsx | 16 +++- .../sponsor/(sponsor)/layout.tsx | 19 ++++- app/(home)/layout.tsx | 75 ++++++++++++++++++- app/api/t1-token/route.ts | 56 ++++++++++++++ app/layout.config.tsx | 6 +- components/login/user-button/UserButton.tsx | 51 ++++++++++--- middleware.ts | 6 +- server/services/auth.ts | 66 ++++++++-------- toolbox/src/versions.json | 2 +- 10 files changed, 267 insertions(+), 56 deletions(-) create mode 100644 app/api/t1-token/route.ts diff --git a/app/(home)/ambassador-dao/onboard/layout.tsx b/app/(home)/ambassador-dao/onboard/layout.tsx index b32e28c21a9..61803bda1f0 100644 --- a/app/(home)/ambassador-dao/onboard/layout.tsx +++ b/app/(home)/ambassador-dao/onboard/layout.tsx @@ -15,14 +15,36 @@ const AmbasssadorDaoOnboardLayout = ({ useEffect(() => { if (!isLoading && !user) { - router.push("/ambassador-dao"); + // Check if the error is due to token acquisition failure + const tokenError = typeof window !== "undefined" + ? localStorage.getItem("t1_token_error") + : null; + + if (tokenError === "user_not_found") { + // User doesn't exist in Ambassador DAO - stay on onboard page to create profile + console.log("User not found in Ambassador DAO, staying on onboard page"); + return; + } else if (tokenError === "server_error") { + // Server error - redirect to home to avoid loop + toast.error("Cannot connect to Ambassador DAO. Please try again later."); + router.push("/"); + } else { + // Normal case: user not authenticated, redirect to main ambassador-dao page + router.push("/ambassador-dao"); + } } }, [user, isLoading, router]); if (isLoading) { return ; } - return
{user && children}
; + + // Allow rendering even without user if error is user_not_found (to show onboarding form) + const tokenError = typeof window !== "undefined" + ? localStorage.getItem("t1_token_error") + : null; + + return
{(user || tokenError === "user_not_found") && children}
; }; export default AmbasssadorDaoOnboardLayout; diff --git a/app/(home)/ambassador-dao/profile/layout.tsx b/app/(home)/ambassador-dao/profile/layout.tsx index 6b61bd6c336..39d43ac5bdc 100644 --- a/app/(home)/ambassador-dao/profile/layout.tsx +++ b/app/(home)/ambassador-dao/profile/layout.tsx @@ -14,7 +14,21 @@ const AmbasssadorDaoProfileLayout = ({ useEffect(() => { if (!isLoading && !user) { - router.push("/ambassador-dao"); + // Check if the error is due to token acquisition failure + const tokenError = typeof window !== "undefined" + ? localStorage.getItem("t1_token_error") + : null; + + if (tokenError === "user_not_found") { + // User doesn't exist in Ambassador DAO - redirect to onboarding + router.push("/ambassador-dao/onboard"); + } else if (tokenError === "server_error") { + // Server error - redirect to home to avoid loop + router.push("/"); + } else { + // Normal case: user not authenticated + router.push("/ambassador-dao"); + } } }, [user, isLoading, router]); diff --git a/app/(home)/ambassador-dao/sponsor/(sponsor)/layout.tsx b/app/(home)/ambassador-dao/sponsor/(sponsor)/layout.tsx index 038cae89a74..d1267518478 100644 --- a/app/(home)/ambassador-dao/sponsor/(sponsor)/layout.tsx +++ b/app/(home)/ambassador-dao/sponsor/(sponsor)/layout.tsx @@ -19,14 +19,27 @@ const AmbasssadorDaoSponsorsLayout = ({ const { data: user, isLoading } = useFetchUserDataQuery(); const [openCreateListingModal, setOpenCreateListingModal] = useState(false); + useEffect(() => { if (!isLoading && !user) { - router.push("/ambassador-dao"); + // Check if the error is due to token acquisition failure + const tokenError = typeof window !== "undefined" + ? localStorage.getItem("t1_token_error") + : null; + + if (tokenError === "user_not_found") { + // User doesn't exist in Ambassador DAO - redirect to onboarding + router.push("/ambassador-dao/onboard"); + } else if (tokenError === "server_error") { + // Server error - redirect to home to avoid loop + router.push("/"); + } else { + // Normal case: user not authenticated + router.push("/ambassador-dao"); + } } else if (user && user.role !== "SPONSOR") { toast.error("You dont have permission to access this page."); router.push("/ambassador-dao"); - } else { - // do nothing } }, [user, isLoading, router]); diff --git a/app/(home)/layout.tsx b/app/(home)/layout.tsx index b3d4dcb08a2..ee9217f212a 100644 --- a/app/(home)/layout.tsx +++ b/app/(home)/layout.tsx @@ -5,8 +5,10 @@ import type { ReactNode } from "react"; import { Footer } from "@/components/navigation/footer"; import { baseOptions } from "@/app/layout.config"; import { SessionProvider, useSession } from "next-auth/react"; -import { useEffect, Suspense } from "react"; +import { useEffect, Suspense, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import axios from "axios"; +import { toast } from "sonner"; export default function Layout({ children, @@ -26,24 +28,91 @@ export default function Layout({ ); } +// Helper function to check if a cookie exists +function getCookie(name: string): string | null { + if (typeof document === 'undefined') return null; + + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + + if (parts.length === 2) { + return parts.pop()?.split(';').shift() || null; + } + + return null; +} + function RedirectIfNewUser() { const { data: session, status } = useSession(); const pathname = usePathname(); const router = useRouter(); const searchParams = useSearchParams(); + const [authError, setAuthError] = useState(null); + + // useEffect #1: Handle external token authentication + useEffect(() => { + const fetchExternalToken = async () => { + if (status !== "authenticated" || !session?.user?.email) return; + + // Check if the external token cookie already exists + const externalToken = getCookie("access_token"); + + if (!externalToken) { + console.log("πŸ”΅ External token not found, obtaining..."); + + try { + await axios.post("/api/t1-token", {}, { + withCredentials: true, + }); + + console.log("βœ… External token obtained and cookie set"); + // Clear any previous errors + if (typeof window !== "undefined") { + localStorage.removeItem("t1_token_error"); + } + } catch (error: any) { + console.error("❌ Failed to get external token:", error); + + // Save error state to prevent infinite loops + if (error.response?.status === 404) { + setAuthError("User not found in Ambassador DAO"); + if (typeof window !== "undefined") { + localStorage.setItem("t1_token_error", "user_not_found"); + } + toast.error("Please complete Ambassador DAO onboarding"); + } else { + setAuthError("Failed to authenticate with Ambassador DAO"); + if (typeof window !== "undefined") { + localStorage.setItem("t1_token_error", "server_error"); + } + toast.error("Authentication error. Please try again."); + } + } + } else { + console.log("βœ… External token cookie already exists"); + // Clear any previous errors when token exists + if (typeof window !== "undefined") { + localStorage.removeItem("t1_token_error"); + } + } + }; + + fetchExternalToken(); + }, [status, session?.user?.email]); + // useEffect #2: Handle new user redirect (original logic) useEffect(() => { if ( status === "authenticated" && session.user.is_new_user && - pathname !== "/profile" + (pathname !== "/profile" && pathname !== "/ambassador-dao/onboard") ) { // Store the original URL with search params (including UTM) in localStorage const originalUrl = `${pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; if (typeof window !== "undefined") { localStorage.setItem("redirectAfterProfile", originalUrl); } - router.replace("/profile"); + router.replace("/ambassador-dao/onboard"); } }, [session, status, pathname, router, searchParams]); diff --git a/app/api/t1-token/route.ts b/app/api/t1-token/route.ts new file mode 100644 index 00000000000..35bd282f0ff --- /dev/null +++ b/app/api/t1-token/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import axios from "axios"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/lib/auth/authOptions"; + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(AuthOptions); + + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const response = await axios.post( + `${process.env.NEXT_PUBLIC_API_URL}/auth/backend/authenticate`, + { + email: session.user.email, + }, + { + withCredentials: true, + headers: { + "Content-Type": "application/json", + "x-api-key": process.env.T1_TOKEN_API_KEY as string, + }, + } + ); + + const nextResponse = NextResponse.json({ + success: true, + message: "External authentication successful", + data: response.data, + }); + + const cookies = response.headers["set-cookie"]; + if (cookies) { + cookies.forEach((cookie) => { + nextResponse.headers.append("Set-Cookie", cookie); + }); + } + + return nextResponse; + } catch (error: any) { + console.error( + "External token error:", + error.response?.data || error.message + ); + + return NextResponse.json( + { + error: "Failed to get external token", + details: error.response?.data?.message || error.message, + }, + { status: error.response?.status || 500 } + ); + } +} diff --git a/app/layout.config.tsx b/app/layout.config.tsx index ddf3bcd4553..77ad0ebe3c5 100644 --- a/app/layout.config.tsx +++ b/app/layout.config.tsx @@ -510,10 +510,6 @@ export const baseOptions: BaseLayoutProps = { integrationsMenu, github, userMenu, - ambassadorMenu, - { - type: "custom", - children: , - }, + ambassadorMenu ], }; diff --git a/components/login/user-button/UserButton.tsx b/components/login/user-button/UserButton.tsx index e66decb01dc..3b2083c4a48 100644 --- a/components/login/user-button/UserButton.tsx +++ b/components/login/user-button/UserButton.tsx @@ -12,12 +12,15 @@ import Image from 'next/image'; import Link from 'next/link'; import SignOutComponent from '../sign-out/SignOut'; import { useState } from 'react'; -import { CircleUserRound, UserRound } from 'lucide-react'; +import { CircleUserRound, UserRound, UserCheck2, User2Icon, ListIcon, LogOut } from 'lucide-react'; import { Separator } from '@radix-ui/react-dropdown-menu'; +import DefaultAvatar from '@/public/ambassador-dao-images/Avatar.svg'; +import { useRouter } from 'next/navigation'; export function UserButton() { const { data: session, status } = useSession() ?? {}; const [isDialogOpen, setIsDialogOpen] = useState(false); const isAuthenticated = status === 'authenticated'; + const router = useRouter(); const handleSignOut = (): void => { // Clean up any stored redirect URLs before logout if (typeof window !== "undefined") { @@ -33,7 +36,6 @@ export function UserButton() { signOut(); }; - console.debug('session', session, isAuthenticated); return ( <> {isAuthenticated ? ( @@ -53,10 +55,7 @@ export function UserButton() { className='rounded-full' /> ) : ( - + DefaultAvatar )} @@ -76,13 +75,45 @@ export function UserButton() { - - Profile - + {/* Onboard option for new users */} + {session.user.is_new_user && ( + <> + router.push("/ambassador-dao/onboard")} + className='cursor-pointer flex items-center gap-2' + > + + Onboard + + + + )} + + {/* Role-based navigation */} + {session.user.role === "SPONSOR" ? ( + router.push("/ambassador-dao/sponsor")} + className='cursor-pointer flex items-center gap-2' + > + + Listings + + ) : ( + + + + Profile + + + )} + + + setIsDialogOpen(true)} - className='cursor-pointer' + className='cursor-pointer flex items-center gap-2 text-red-500' > + Sign Out diff --git a/middleware.ts b/middleware.ts index 27b166d3b78..282746c243e 100644 --- a/middleware.ts +++ b/middleware.ts @@ -33,7 +33,9 @@ export async function middleware(req: NextRequest) { "/hackathons/registration-form", "/hackathons/project-submission", "/showcase", - "/profile" + "/profile", + "/ambassador-dao/profile", + "/ambassador-dao/onboard" ]; const isProtectedPath = protectedPaths.some(path => pathname.startsWith(path)); @@ -80,5 +82,7 @@ export const config = { "/showcase/:path*", "/login/:path*", "/profile/:path*", + "/ambassador-dao/profile/:path*", + "/ambassador-dao/onboard/:path*", ], }; diff --git a/server/services/auth.ts b/server/services/auth.ts index 3487bc4be9a..d213b683c61 100644 --- a/server/services/auth.ts +++ b/server/services/auth.ts @@ -7,35 +7,41 @@ export async function upsertUser(user: User, account: Account | null, profile: P throw new Error("El usuario debe tener un email vΓ‘lido"); } - - const existingUser = await prisma.user.findUnique({ - where: { email: user.email }, - }); - - - const updatedAuthMode = existingUser?.authentication_mode?.includes(account?.provider ?? "") - ? existingUser.authentication_mode - : `${existingUser?.authentication_mode ?? ""},${account?.provider}`.replace(/^,/, ""); - - return await prisma.user.upsert({ - where: { email: user.email }, - update: { - name: user.name || "", - image: existingUser?.image || user.image || "", - authentication_mode: updatedAuthMode, - last_login: new Date(), - user_name: (profile as any)?.login ?? "", - }, - create: { - email: user.email, - notification_email: user.email, - name: user.name || "", - image: user.image || "", - authentication_mode: account?.provider ?? "", - last_login: new Date(), - user_name: (profile as any)?.login ?? "", - notifications: null, - }, + return await prisma.$transaction(async (tx) => { + const existingUser = await tx.user.findUnique({ + where: { email: user.email ?? undefined }, + }); + + const updatedAuthMode = existingUser?.authentication_mode?.includes(account?.provider ?? "") + ? existingUser.authentication_mode + : `${existingUser?.authentication_mode ?? ""},${account?.provider}`.replace(/^,/, ""); + + if (existingUser) { + // Update existing user + return await tx.user.update({ + where: { email: user.email ?? undefined }, + data: { + name: user.name || "", + image: existingUser.image || user.image || "", + authentication_mode: updatedAuthMode, + last_login: new Date(), + user_name: (profile as any)?.login ?? "", + }, + }); + } else { + // Create new user + return await tx.user.create({ + data: { + email: user.email, + notification_email: user.email ?? "", + name: user.name || "", + image: user.image || "", + authentication_mode: account?.provider ?? "", + last_login: new Date(), + user_name: (profile as any)?.login ?? "", + notifications: null, + }, + }); + } }); - } diff --git a/toolbox/src/versions.json b/toolbox/src/versions.json index 83bd8ad1330..034fe3c936f 100644 --- a/toolbox/src/versions.json +++ b/toolbox/src/versions.json @@ -1,5 +1,5 @@ { "avaplatform/avalanchego": "v1.13.4", - "avaplatform/subnet-evm_avalanchego": "v0.7.8_v1.13.4", + "avaplatform/subnet-evm_avalanchego": "v0.7.9_v1.13.5", "avaplatform/icm-relayer": "v1.6.6" } \ No newline at end of file From 538c559aee10143f708f776ccea4399fd79e19ca Mon Sep 17 00:00:00 2001 From: jhoan Date: Wed, 8 Oct 2025 12:05:40 -0500 Subject: [PATCH 02/12] Enhance onboarding experience for Ambassador DAO users - Added new fields to the TalentForm and SponsorForm components, including bio, telegram user, profile privacy, and email notifications. - Integrated a custom hook for managing talent profile logic, improving state management and file uploads. - Updated the IUpdateTalentProfileBody interface to accommodate new fields. - Improved user interface consistency by standardizing class names and styles across components. - Enhanced error handling and data fetching for local profiles, ensuring a smoother onboarding process. --- app/(home)/ambassador-dao/onboard/page.tsx | 575 ++++++++++++------ hooks/useTalentProfile.ts | 174 ++++++ services/ambassador-dao/interfaces/onbaord.ts | 4 + 3 files changed, 559 insertions(+), 194 deletions(-) create mode 100644 hooks/useTalentProfile.ts diff --git a/app/(home)/ambassador-dao/onboard/page.tsx b/app/(home)/ambassador-dao/onboard/page.tsx index 1078bbdd009..8519a66e393 100644 --- a/app/(home)/ambassador-dao/onboard/page.tsx +++ b/app/(home)/ambassador-dao/onboard/page.tsx @@ -36,6 +36,10 @@ import { countries } from "@/services/ambassador-dao/data/locations"; import { useUpdateWalletAddress } from "@/services/ambassador-dao/requests/users"; import Loader from "@/components/ambassador-dao/ui/Loader"; import FileUploader from "@/components/ambassador-dao/ui/FileUploader"; +import { useSession } from "next-auth/react"; +import axios from "axios"; +import { Switch } from "@/components/ui/switch"; +import { useTalentProfile } from "@/hooks/useTalentProfile"; const userTypes = [ { @@ -95,9 +99,9 @@ const AmbasssadorDaoOnboardPage = () => { } return ( -
+
{selectionStep === "account_option" && ( -
+
{userTypes.map((type, idx) => (
{ `} onClick={() => setUserType(type.name.toUpperCase() as any)} > -
-
- +
+
+
-
-

- Continue as {type.name} +

+

+ Continue as {type.name}

{type.description}

-
+
-
+
avalance icon
-
+
-
+
{type.perks.map((perk, idx) => (
- -

{perk}

+ +

{perk}

))}
-
+
-
+
handleContinue(type.name.toUpperCase() as any)} - className='px-6 h-10 text-sm font-medium' + className="px-6 h-10 text-sm font-medium" > Continue as {type.name} @@ -167,7 +171,7 @@ const AmbasssadorDaoOnboardPage = () => {
)} {selectionStep === "account_form" && ( -
+
{userType === ("USER" as "TALENT") && } {userType === ("AMBASSADOR" as "TALENT") && } {userType === "SPONSOR" && } @@ -182,6 +186,7 @@ export default AmbasssadorDaoOnboardPage; const TalentForm = () => { const router = useRouter(); const { data: userData } = useFetchUserDataQuery(); + const { data: session } = useSession(); const [isDataFetched, setIsDataFetched] = useState(false); const { register, @@ -199,6 +204,11 @@ const TalentForm = () => { location: "", job_title: "", years_of_experience: "", + bio: "", + profile_image: "", + telegram_user: "", + profile_privacy: "public", + notifications: false, }, }); const [selectedSkills, setSelectedSkills] = useState([]); @@ -217,12 +227,33 @@ const TalentForm = () => { const username = watch("username"); const location = watch("location"); + const profile_image = watch("profile_image"); const { mutate: updateTalentProfile, isPending: isUpdatingProfile } = useUpdateTalentProfileMutation(); const { mutate: checkUsername } = useCheckUsernameAvailabilityMutation(); const { data: skills } = useFetchAllSkills(); + // Use custom hook for talent profile logic + const { + localProfileData, + previewImage, + isUploadingProfileImage, + profileImageName, + profileImageSize, + isUploading, + handleProfileImageUpload, + removeFile, + saveToLocalProfile, + onSkip, + } = useTalentProfile({ + setValue, + watch, + selectedSkills, + socialLinks, + setIsDataFetched, + }); + useEffect(() => { if (userData) { if (userData.first_name && userData.wallet_address && isOnboardPage) { @@ -237,6 +268,11 @@ const TalentForm = () => { job_title: userData.job_title || "", social_links: userData.social_links || [], years_of_experience: userData.years_of_experience || "", + bio: localProfileData?.bio || "", + profile_image: localProfileData?.image || userData.profile_image || "", + telegram_user: localProfileData?.telegram_user || "", + profile_privacy: localProfileData?.profile_privacy || "public", + notifications: localProfileData?.notifications ?? false, }); if (userData.skills && userData.skills.length > 0) { @@ -262,7 +298,14 @@ const TalentForm = () => { setIsDataFetched(true); } - }, [userData, router, isEditProfilePage, isOnboardPage, reset]); + }, [ + userData, + localProfileData, + router, + isEditProfilePage, + isOnboardPage, + reset, + ]); useEffect(() => { if (username && username.length > 3) { @@ -335,7 +378,14 @@ const TalentForm = () => { years_of_experience: +data.years_of_experience, }, { - onSuccess: () => { + onSuccess: async () => { + // Save to local User table + try { + await saveToLocalProfile(data); + } catch (error) { + // No bloqueamos el flujo + } + if (isEditProfilePage) { return; } else { @@ -352,7 +402,16 @@ const TalentForm = () => { wallet_address: data.wallet_address, }, { - onSuccess: () => { + onSuccess: async () => { + // Update local User table with final data + try { + const formData = watch(); + await saveToLocalProfile(formData); + console.log("βœ… Local profile updated with wallet"); + } catch (error) { + console.error("❌ Error updating local profile:", error); + } + router.push("/ambassador-dao"); }, } @@ -364,16 +423,16 @@ const TalentForm = () => { if (!isDataFetched) { return ( -
- +
+
); } return (
-
-

+
+

{isEditProfilePage ? stage === 1 ? "Edit Your Profile" @@ -383,136 +442,160 @@ const TalentForm = () => { : "Add a wallet address"}

-

+

{isEditProfilePage ? "Update your profile information and wallet details." : "It takes less than a minute to start earning in global standards."}

-
+
{stage === 1 && (
-
+ {" "} + +
-
+
+ +
+
- -
-
+
+
{" "} {usernameStatus === "checking" && ( )} {usernameStatus === "available" && ( )} {usernameStatus === "unavailable" && ( )} } /> {usernameStatus === "unavailable" && ( -

+

Username is already taken

)} {usernameError && ( -

{usernameError}

+

{usernameError}

)}
- + {countries.map((country, idx) => ( - ))}
-
-

+

+

Your skills - * + *

-
+
{selectedSkills && !!selectedSkills.length && selectedSkills.map((badge, idx) => (
removeSkill(badge)} > {skills?.find((skill) => skill.id === badge)?.name} - +
))}
-
+
{skills && !!skills.length && skills @@ -520,31 +603,31 @@ const TalentForm = () => { .map((badge, idx) => (
addSkill(badge.id)} > {badge.name} - +
))} {!skills?.length && ( <> -

+

No skills available

)}
-
+
{socialLinks.map((link, idx) => (
{ @@ -554,10 +637,10 @@ const TalentForm = () => { }} /> {socialLinks.length > 1 && ( -
+
)}
))} -
+
-
-
+ {/* Telegram User */} +
+ + +

+ We can be in touch through telegram. +

+
+ + {/* Profile Privacy */} +
+ + + + + + +

+ Choose who can see your profile +

+
+ + {/* Email Notifications */} +
+

Email Notifications

+
+
+

+ I wish to stay informed about Avalanche news and events and + agree to receive newsletters and other promotional materials + at the email address I provided. {"\n"}I know that I + may opt-out at any time. I have read and agree to the{" "} + + Avalanche Privacy Policy + + . +

+
+ setValue("notifications", checked, { shouldDirty: true })} + /> +
+
+ +
+
{isEditProfilePage ? "Submit Updated Details" : "Create Profile"} - {/* {isEditProfilePage && ( - setStage(2)} - > - Edit Wallet - - )} */} + + Skip +
)} @@ -615,35 +758,35 @@ const TalentForm = () => { {stage === 2 && (
-
-
+
+
{isEditProfilePage ? "Update Wallet" : "Connect Wallet"} {isEditProfilePage && ( setStage(1)} > Back to Profile @@ -661,8 +804,10 @@ const SponsorForm = () => { const [previewImage, setPreviewImage] = useState(null); const [isUploadingProfileImage, setIsUploadingProfileImage] = useState(false); const [isUploadingLogo, setIsUploadingLogo] = useState(false); + const [localProfileData, setLocalProfileData] = useState(null); const { data: userData } = useFetchUserDataQuery(); + const { data: session } = useSession(); const router = useRouter(); const { register, @@ -718,6 +863,29 @@ const SponsorForm = () => { const pathname = usePathname(); const isEditProfilePage = pathname === "/ambassador-dao/edit-profile"; + // Fetch local profile data to get bio + useEffect(() => { + const fetchLocalProfile = async () => { + if (session?.user?.id) { + try { + const response = await axios.get(`/api/profile/${session.user.id}`); + setLocalProfileData(response.data); + } catch (error) { + console.error("Error fetching local profile:", error); + } + } + }; + + fetchLocalProfile(); + }, [session?.user?.id]); + + // Update short_bio from local profile when available + useEffect(() => { + if (localProfileData?.bio && isEditProfilePage) { + setValue("short_bio", localProfileData.bio); + } + }, [localProfileData, isEditProfilePage, setValue]); + useEffect(() => { if (username && username.length > 3) { setUsernameStatus("checking"); @@ -849,7 +1017,26 @@ const SponsorForm = () => { logo: data.logo || "", }, { - onSuccess: () => { + onSuccess: async () => { + // Save to local User table + if (session?.user?.id && session?.user?.email) { + try { + await axios.put(`/api/profile/${session.user.id}`, { + name: `${data.first_name} ${data.last_name}`.trim(), + bio: data.short_bio || "", + email: session.user.email, + notification_email: session.user.email, + image: data.profile_image || "", + social_media: [], // Sponsor no tiene social links en este form + notifications: false, + profile_privacy: "public", + }); + console.log("βœ… Local sponsor profile updated successfully"); + } catch (error) { + console.error("❌ Error updating local profile:", error); + } + } + router.push("/ambassador-dao/sponsor"); }, } @@ -858,95 +1045,95 @@ const SponsorForm = () => { return (
-
-

+
+

Welcome to Team 1

-

+

Get access to top global talents


-

+

About you


-
+
-
-
+
+
{" "} {usernameStatus === "checking" && ( )} {usernameStatus === "available" && ( )} {usernameStatus === "unavailable" && ( )} } /> {usernameStatus === "unavailable" && ( -

+

Username is already taken

)} {usernameError && ( -

{usernameError}

+

{usernameError}

)}
- + {countries.map((country, idx) => ( - ))} @@ -960,90 +1147,90 @@ const SponsorForm = () => { fileName={profileImageName} handleFileUpload={handleProfileImageUpload} isUploading={isUploadingProfileImage && isUploading} - accept='.png,.jpg,.jpeg,.svg' - inputId='profileImage' - label='Upload Profile Image or Avatar' + accept=".png,.jpg,.jpeg,.svg" + inputId="profileImage" + label="Upload Profile Image or Avatar" required={true} - recommendedSize='Add the image here. Recommended size: 512 x 512px (square format)' - allowedFileTypes='JPG, PNG, SVG' + recommendedSize="Add the image here. Recommended size: 512 x 512px (square format)" + allowedFileTypes="JPG, PNG, SVG" />
-

+

About Your Company


-
-
+
+
-
+
{" "} {companyUsernameStatus === "checking" && ( )} {companyUsernameStatus === "available" && ( )} {companyUsernameStatus === "unavailable" && ( )} } /> {companyUsernameStatus === "unavailable" && ( -

+

Company username is already taken

)} {companyUsernameError && ( -

+

{companyUsernameError}

)}
-
+
@@ -1056,36 +1243,36 @@ const SponsorForm = () => { fileName={companyLogoName} handleFileUpload={handleCompanyLogoUpload} isUploading={isUploadingLogo && isUploading} - accept='.png,.jpg,.jpeg,.svg' - inputId='companyLogoInput' - label='Company Logo' + accept=".png,.jpg,.jpeg,.svg" + inputId="companyLogoInput" + label="Company Logo" required={true} - recommendedSize='Add the image here. Recommended size: 512 x 512px (square format)' - allowedFileTypes='JPG, PNG, SVG' + recommendedSize="Add the image here. Recommended size: 512 x 512px (square format)" + allowedFileTypes="JPG, PNG, SVG" />
-
-
+
+
{isEditProfilePage ? "Submit Updated Details" : "Create Sponsor"} diff --git a/hooks/useTalentProfile.ts b/hooks/useTalentProfile.ts new file mode 100644 index 00000000000..82cd0038a92 --- /dev/null +++ b/hooks/useTalentProfile.ts @@ -0,0 +1,174 @@ +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import axios from "axios"; +import toast from "react-hot-toast"; +import { useFileUploadMutation, useUpdateTalentProfileMutation } from "@/services/ambassador-dao/requests/onboard"; +import { UseFormSetValue, UseFormWatch } from "react-hook-form"; +import { IUpdateTalentProfileBody } from "@/services/ambassador-dao/interfaces/onbaord"; + +interface UseTalentProfileProps { + setValue: UseFormSetValue; + watch: UseFormWatch; + selectedSkills: string[]; + socialLinks: string[]; + setIsDataFetched: (value: boolean) => void; +} + +export const useTalentProfile = ({ + setValue, + watch, + selectedSkills, + socialLinks, + setIsDataFetched, +}: UseTalentProfileProps) => { + const router = useRouter(); + const { data: session } = useSession(); + const [localProfileData, setLocalProfileData] = useState(null); + const [previewImage, setPreviewImage] = useState(null); + const [isUploadingProfileImage, setIsUploadingProfileImage] = useState(false); + const [profileImageName, setProfileImageName] = useState(""); + const [profileImageSize, setProfileImageSize] = useState(); + + const { mutateAsync: uploadFile, isPending: isUploading } = useFileUploadMutation("image"); + const { mutate: updateTalentProfile } = useUpdateTalentProfileMutation(); + + // Fetch local profile data to get bio, image, etc. + useEffect(() => { + const fetchLocalProfile = async () => { + if (session?.user?.id) { + try { + const response = await axios.get(`/api/profile/${session.user.id}`); + setLocalProfileData(response.data); + } catch (error) { + console.error("Error fetching local profile:", error); + } + } + }; + + fetchLocalProfile(); + }, [session?.user?.id]); + + // Handle profile image upload + const handleProfileImageUpload = async (file: File) => { + const allowedTypes = ["image/jpeg", "image/png", "image/svg+xml"]; + if (!allowedTypes.includes(file.type)) { + toast.error("Only JPG, PNG, and SVG images are allowed"); + return; + } + + if (file.size > 1024 * 1024) { + toast.error("File size exceeds 1MB limit"); + return; + } else { + setProfileImageSize(file.size); + } + + try { + setIsUploadingProfileImage(true); + setProfileImageName(file.name); + + const reader = new FileReader(); + reader.onloadend = () => { + setPreviewImage(reader.result as string); + }; + setIsUploadingProfileImage(false); + reader.readAsDataURL(file); + + const url = await uploadFile(file); + setValue("profile_image", url.url); + } catch (error) { + console.error("Error uploading image:", error); + toast.error("Failed to upload image"); + } + }; + + // Remove profile image + const removeFile = () => { + setValue("profile_image", ""); + setPreviewImage(""); + }; + + // Save to local User table + const saveToLocalProfile = async (formData: any) => { + if (session?.user?.id && session?.user?.email) { + try { + await axios.put(`/api/profile/${session.user.id}`, { + name: `${formData.first_name} ${formData.last_name}`.trim(), + bio: formData.bio || "", + email: session.user.email, + notification_email: session.user.email, + image: formData.profile_image || "", + social_media: socialLinks || [], + notifications: formData.notifications, + profile_privacy: formData.profile_privacy, + telegram_user: formData.telegram_user || "", + }); + console.log("βœ… Local profile updated successfully"); + } catch (error) { + console.error("❌ Error updating local profile:", error); + throw error; + } + } + }; + + // Skip function + const onSkip = async () => { + // Validate that first_name is filled before allowing skip + const currentFirstName = watch("first_name"); + if (!currentFirstName || currentFirstName.trim() === "") { + toast.error("First name is required to continue."); + return; + } + + // Save the current form data before skipping + setIsDataFetched(false); // Show loading + try { + const formData = watch(); + + // Save to Ambassador API + await new Promise((resolve, reject) => { + updateTalentProfile( + { + ...formData, + skill_ids: selectedSkills, + social_links: socialLinks, + years_of_experience: formData.years_of_experience, + }, + { + onSuccess: () => resolve(true), + onError: (error: any) => reject(error), + } + ); + }); + + // Save to local User table + await saveToLocalProfile(formData); + + toast.success("Profile saved successfully!"); + router.push("/ambassador-dao"); + } catch (error) { + console.error(error); + toast.error("Error while saving profile."); + } finally { + setIsDataFetched(true); + } + }; + + return { + // States + localProfileData, + previewImage, + isUploadingProfileImage, + profileImageName, + profileImageSize, + isUploading, + + // Functions + handleProfileImageUpload, + removeFile, + saveToLocalProfile, + onSkip, + }; +}; + diff --git a/services/ambassador-dao/interfaces/onbaord.ts b/services/ambassador-dao/interfaces/onbaord.ts index dad9b2c8a58..8ad8d1c06d8 100644 --- a/services/ambassador-dao/interfaces/onbaord.ts +++ b/services/ambassador-dao/interfaces/onbaord.ts @@ -24,6 +24,10 @@ export interface IUpdateTalentProfileBody { social_links: string[]; wallet_address: string; years_of_experience: string; + bio: string; + telegram_user: string; + profile_privacy: string; + notifications: boolean; } export interface ISkillsResponse { From f49a95f42c6659cb4bda968662d144558ab5661c Mon Sep 17 00:00:00 2001 From: jhoan Date: Thu, 9 Oct 2025 11:55:42 -0500 Subject: [PATCH 03/12] Refactor onboarding and error handling for Ambassador DAO - Improved the handling of external token acquisition and error states in the RedirectIfNewUser component. - Updated the TalentForm to include manual validation for skills and social links, enhancing user feedback. - Modified the saveToLocalProfile function to accept social links, ensuring comprehensive profile updates. - Streamlined the onSkip function to save user data without requiring full validation, improving user experience during onboarding. - Adjusted the SignOut component to ensure proper logout functionality with API integration. --- app/(home)/ambassador-dao/onboard/layout.tsx | 2 +- app/(home)/ambassador-dao/onboard/page.tsx | 74 ++++++++++++-------- app/(home)/layout.tsx | 16 ++--- components/login/sign-out/SignOut.tsx | 3 + hooks/useTalentProfile.ts | 46 +++--------- 5 files changed, 64 insertions(+), 77 deletions(-) diff --git a/app/(home)/ambassador-dao/onboard/layout.tsx b/app/(home)/ambassador-dao/onboard/layout.tsx index 61803bda1f0..29423baada7 100644 --- a/app/(home)/ambassador-dao/onboard/layout.tsx +++ b/app/(home)/ambassador-dao/onboard/layout.tsx @@ -27,7 +27,7 @@ const AmbasssadorDaoOnboardLayout = ({ } else if (tokenError === "server_error") { // Server error - redirect to home to avoid loop toast.error("Cannot connect to Ambassador DAO. Please try again later."); - router.push("/"); + return; } else { // Normal case: user not authenticated, redirect to main ambassador-dao page router.push("/ambassador-dao"); diff --git a/app/(home)/ambassador-dao/onboard/page.tsx b/app/(home)/ambassador-dao/onboard/page.tsx index 8519a66e393..d16238dd059 100644 --- a/app/(home)/ambassador-dao/onboard/page.tsx +++ b/app/(home)/ambassador-dao/onboard/page.tsx @@ -196,6 +196,7 @@ const TalentForm = () => { setValue, watch, reset, + getValues, } = useForm({ defaultValues: { first_name: "", @@ -249,8 +250,7 @@ const TalentForm = () => { } = useTalentProfile({ setValue, watch, - selectedSkills, - socialLinks, + getValues, setIsDataFetched, }); @@ -369,7 +369,22 @@ const TalentForm = () => { setValue("social_links", updated); }; + const handleSkip = () => { + onSkip(); + }; + const onSubmit = (data: any) => { + // Validate skills and social links manually + if (!selectedSkills.length) { + toast.error("Please select at least one skill"); + return; + } + + if (!socialLinks.length || !socialLinks.some(link => link.trim() !== "")) { + toast.error("Please add at least one social link"); + return; + } + updateTalentProfile( { ...data, @@ -381,7 +396,7 @@ const TalentForm = () => { onSuccess: async () => { // Save to local User table try { - await saveToLocalProfile(data); + await saveToLocalProfile(data, socialLinks); } catch (error) { // No bloqueamos el flujo } @@ -406,7 +421,7 @@ const TalentForm = () => { // Update local User table with final data try { const formData = watch(); - await saveToLocalProfile(formData); + await saveToLocalProfile(formData, socialLinks); console.log("βœ… Local profile updated with wallet"); } catch (error) { console.error("❌ Error updating local profile:", error); @@ -476,15 +491,15 @@ const TalentForm = () => { id="firstName" label="First Name" placeholder="First Name" - required - {...register("first_name")} + error={errors.first_name} + {...register("first_name", { required: "First name is required" })} />
@@ -492,8 +507,8 @@ const TalentForm = () => { id="bio" label="Bio" placeholder="Tell others about yourself in a few words" - required - {...register("bio")} + error={errors.bio} + {...register("bio", { required: "Bio is required" })} />
@@ -501,16 +516,16 @@ const TalentForm = () => { id="job_title" label="Job Title" placeholder="Job Title" - required - {...register("job_title")} + error={errors.job_title} + {...register("job_title", { required: "Job title is required" })} />
@@ -519,8 +534,8 @@ const TalentForm = () => { id="userName" label="User Name" placeholder="User Name" - required - {...register("username")} + error={errors.username} + {...register("username", { required: "Username is required" })} className="relative" icon={ <> @@ -563,8 +578,7 @@ const TalentForm = () => { {countries.map((country, idx) => ( @@ -736,21 +750,23 @@ const TalentForm = () => { type="submit" isFullWidth={false} className="px-6" - disabled={!socialLinks.length || !selectedSkills.length} + disabled={isUpdatingProfile} > {isEditProfilePage ? "Submit Updated Details" : "Create Profile"} - - Skip - + {!isEditProfilePage && ( + + Skip + + )}
)} diff --git a/app/(home)/layout.tsx b/app/(home)/layout.tsx index ee9217f212a..316d9f28176 100644 --- a/app/(home)/layout.tsx +++ b/app/(home)/layout.tsx @@ -64,33 +64,25 @@ function RedirectIfNewUser() { await axios.post("/api/t1-token", {}, { withCredentials: true, }); - - console.log("βœ… External token obtained and cookie set"); - // Clear any previous errors + if (typeof window !== "undefined") { localStorage.removeItem("t1_token_error"); } } catch (error: any) { console.error("❌ Failed to get external token:", error); - - // Save error state to prevent infinite loops if (error.response?.status === 404) { setAuthError("User not found in Ambassador DAO"); if (typeof window !== "undefined") { localStorage.setItem("t1_token_error", "user_not_found"); } - toast.error("Please complete Ambassador DAO onboarding"); } else { setAuthError("Failed to authenticate with Ambassador DAO"); if (typeof window !== "undefined") { localStorage.setItem("t1_token_error", "server_error"); } - toast.error("Authentication error. Please try again."); } } } else { - console.log("βœ… External token cookie already exists"); - // Clear any previous errors when token exists if (typeof window !== "undefined") { localStorage.removeItem("t1_token_error"); } @@ -100,14 +92,16 @@ function RedirectIfNewUser() { fetchExternalToken(); }, [status, session?.user?.email]); - // useEffect #2: Handle new user redirect (original logic) + useEffect(() => { + const errorLocalStorage = localStorage.getItem("t1_token_error"); if ( status === "authenticated" && session.user.is_new_user && (pathname !== "/profile" && pathname !== "/ambassador-dao/onboard") + && errorLocalStorage != "" ) { - // Store the original URL with search params (including UTM) in localStorage + const originalUrl = `${pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; if (typeof window !== "undefined") { localStorage.setItem("redirectAfterProfile", originalUrl); diff --git a/components/login/sign-out/SignOut.tsx b/components/login/sign-out/SignOut.tsx index 4785b81d80b..655681ab0e8 100644 --- a/components/login/sign-out/SignOut.tsx +++ b/components/login/sign-out/SignOut.tsx @@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter } from "@/components/ui/card"; import { LoadingButton } from "@/components/ui/loading-button"; import Modal from "@/components/ui/Modal"; +import axios from "axios"; import { BadgeAlert } from "lucide-react"; import React, { useState } from "react"; @@ -24,6 +25,8 @@ export default function SignOutComponent({ onConfirm(), new Promise((resolve) => setTimeout(resolve, 300)), ]); + const url = `${process.env.NEXT_PUBLIC_API_URL}/auth/logout`; + await axios.post(url); onOpenChange(false); } finally { setIsConfirm(false); diff --git a/hooks/useTalentProfile.ts b/hooks/useTalentProfile.ts index 82cd0038a92..6d8c9cbcb5c 100644 --- a/hooks/useTalentProfile.ts +++ b/hooks/useTalentProfile.ts @@ -3,23 +3,21 @@ import { useRouter } from "next/navigation"; import { useSession } from "next-auth/react"; import axios from "axios"; import toast from "react-hot-toast"; -import { useFileUploadMutation, useUpdateTalentProfileMutation } from "@/services/ambassador-dao/requests/onboard"; -import { UseFormSetValue, UseFormWatch } from "react-hook-form"; +import { useFileUploadMutation } from "@/services/ambassador-dao/requests/onboard"; +import { UseFormSetValue, UseFormWatch, UseFormGetValues } from "react-hook-form"; import { IUpdateTalentProfileBody } from "@/services/ambassador-dao/interfaces/onbaord"; interface UseTalentProfileProps { setValue: UseFormSetValue; watch: UseFormWatch; - selectedSkills: string[]; - socialLinks: string[]; + getValues: UseFormGetValues; setIsDataFetched: (value: boolean) => void; } export const useTalentProfile = ({ setValue, watch, - selectedSkills, - socialLinks, + getValues, setIsDataFetched, }: UseTalentProfileProps) => { const router = useRouter(); @@ -31,7 +29,6 @@ export const useTalentProfile = ({ const [profileImageSize, setProfileImageSize] = useState(); const { mutateAsync: uploadFile, isPending: isUploading } = useFileUploadMutation("image"); - const { mutate: updateTalentProfile } = useUpdateTalentProfileMutation(); // Fetch local profile data to get bio, image, etc. useEffect(() => { @@ -90,7 +87,7 @@ export const useTalentProfile = ({ }; // Save to local User table - const saveToLocalProfile = async (formData: any) => { + const saveToLocalProfile = async (formData: any, socialLinks?: string[]) => { if (session?.user?.id && session?.user?.email) { try { await axios.put(`/api/profile/${session.user.id}`, { @@ -112,37 +109,14 @@ export const useTalentProfile = ({ } }; - // Skip function + // Skip function - Only saves to local User table, NOT to Ambassador API const onSkip = async () => { - // Validate that first_name is filled before allowing skip - const currentFirstName = watch("first_name"); - if (!currentFirstName || currentFirstName.trim() === "") { - toast.error("First name is required to continue."); - return; - } - - // Save the current form data before skipping + // Save the current form data before skipping (no validation required) setIsDataFetched(false); // Show loading try { - const formData = watch(); - - // Save to Ambassador API - await new Promise((resolve, reject) => { - updateTalentProfile( - { - ...formData, - skill_ids: selectedSkills, - social_links: socialLinks, - years_of_experience: formData.years_of_experience, - }, - { - onSuccess: () => resolve(true), - onError: (error: any) => reject(error), - } - ); - }); - - // Save to local User table + const formData = getValues(); + + // Only save to local User table - save whatever data is available await saveToLocalProfile(formData); toast.success("Profile saved successfully!"); From 7c6c24e69b637ad518a0f0cd7bf86139f4bb470d Mon Sep 17 00:00:00 2001 From: jhoan Date: Fri, 10 Oct 2025 08:12:34 -0500 Subject: [PATCH 04/12] Implement redirect logic after profile update in TalentForm and useTalentProfile hook - Added functionality to check for a stored redirect URL after saving the profile, allowing users to navigate to a specified page if available. - Updated both the TalentForm and useTalentProfile hook to enhance user experience by ensuring proper navigation post-profile update. --- app/(home)/ambassador-dao/onboard/page.tsx | 12 +++++++++++- hooks/useTalentProfile.ts | 15 +++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/(home)/ambassador-dao/onboard/page.tsx b/app/(home)/ambassador-dao/onboard/page.tsx index d16238dd059..8b58bc3d080 100644 --- a/app/(home)/ambassador-dao/onboard/page.tsx +++ b/app/(home)/ambassador-dao/onboard/page.tsx @@ -427,7 +427,17 @@ const TalentForm = () => { console.error("❌ Error updating local profile:", error); } - router.push("/ambassador-dao"); + // Check for stored redirect URL and navigate there, otherwise go to ambassador-dao + const redirectUrl = typeof window !== "undefined" + ? localStorage.getItem("redirectAfterProfile") + : null; + + if (redirectUrl) { + localStorage.removeItem("redirectAfterProfile"); + router.push(redirectUrl); + } else { + router.push("/ambassador-dao"); + } }, } ); diff --git a/hooks/useTalentProfile.ts b/hooks/useTalentProfile.ts index 6d8c9cbcb5c..3d44e78766b 100644 --- a/hooks/useTalentProfile.ts +++ b/hooks/useTalentProfile.ts @@ -118,9 +118,20 @@ export const useTalentProfile = ({ // Only save to local User table - save whatever data is available await saveToLocalProfile(formData); - + toast.success("Profile saved successfully!"); - router.push("/ambassador-dao"); + + // Check for stored redirect URL and navigate there, otherwise go to ambassador-dao + const redirectUrl = typeof window !== "undefined" + ? localStorage.getItem("redirectAfterProfile") + : null; + + if (redirectUrl) { + localStorage.removeItem("redirectAfterProfile"); + router.push(redirectUrl); + } else { + router.push("/ambassador-dao"); + } } catch (error) { console.error(error); toast.error("Error while saving profile."); From b3351b640e672bc976b4eab63049f4a7a674dcc2 Mon Sep 17 00:00:00 2001 From: jhoan Date: Fri, 10 Oct 2025 08:56:45 -0500 Subject: [PATCH 05/12] Add modal confirmation for new user profile completion in RedirectIfNewUser component - Introduced a modal to prompt new users to complete their profile information before proceeding. - Updated the redirect logic to show the modal when certain conditions are met, enhancing user experience during onboarding. - Refactored code for improved readability and consistency in handling cookies and external token acquisition. --- app/(home)/layout.tsx | 67 ++++++++++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 16 deletions(-) diff --git a/app/(home)/layout.tsx b/app/(home)/layout.tsx index 316d9f28176..7eab6067329 100644 --- a/app/(home)/layout.tsx +++ b/app/(home)/layout.tsx @@ -9,6 +9,8 @@ import { useEffect, Suspense, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import axios from "axios"; import { toast } from "sonner"; +import Modal from "@/components/ui/Modal"; +import { Button } from "@/components/ui/button"; export default function Layout({ children, @@ -30,15 +32,15 @@ export default function Layout({ // Helper function to check if a cookie exists function getCookie(name: string): string | null { - if (typeof document === 'undefined') return null; - + if (typeof document === "undefined") return null; + const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); - + if (parts.length === 2) { - return parts.pop()?.split(';').shift() || null; + return parts.pop()?.split(";").shift() || null; } - + return null; } @@ -48,6 +50,7 @@ function RedirectIfNewUser() { const router = useRouter(); const searchParams = useSearchParams(); const [authError, setAuthError] = useState(null); + const [showModal, setShowModal] = useState(false); // useEffect #1: Handle external token authentication useEffect(() => { @@ -56,14 +59,18 @@ function RedirectIfNewUser() { // Check if the external token cookie already exists const externalToken = getCookie("access_token"); - + if (!externalToken) { console.log("πŸ”΅ External token not found, obtaining..."); - + try { - await axios.post("/api/t1-token", {}, { - withCredentials: true, - }); + await axios.post( + "/api/t1-token", + {}, + { + withCredentials: true, + } + ); if (typeof window !== "undefined") { localStorage.removeItem("t1_token_error"); @@ -92,23 +99,51 @@ function RedirectIfNewUser() { fetchExternalToken(); }, [status, session?.user?.email]); - useEffect(() => { const errorLocalStorage = localStorage.getItem("t1_token_error"); if ( status === "authenticated" && session.user.is_new_user && - (pathname !== "/profile" && pathname !== "/ambassador-dao/onboard") - && errorLocalStorage != "" + pathname !== "/profile" && + pathname !== "/ambassador-dao/onboard" && + errorLocalStorage != "" ) { - - const originalUrl = `${pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + const originalUrl = `${pathname}${ + searchParams.toString() ? `?${searchParams.toString()}` : "" + }`; if (typeof window !== "undefined") { localStorage.setItem("redirectAfterProfile", originalUrl); } + + // Show confirmation modal and redirect immediately + router.replace("/ambassador-dao/onboard"); + setShowModal(true); } }, [session, status, pathname, router, searchParams]); - return null; + const handleContinue = () => { + setShowModal(false); + }; + + return ( + <> + {showModal && ( + + +
+ } + /> + )} + + ); } From 8bf3fd2eb566475532a64a57326065fb92d477a1 Mon Sep 17 00:00:00 2001 From: jhoan Date: Fri, 10 Oct 2025 09:04:16 -0500 Subject: [PATCH 06/12] Refactor RedirectIfNewUser component for improved readability - Removed commented-out code and unnecessary console logs to enhance code clarity. - Streamlined the external token authentication logic, ensuring a cleaner implementation. - Adjusted the redirect logic to maintain user experience during onboarding. --- app/(home)/layout.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/app/(home)/layout.tsx b/app/(home)/layout.tsx index 7eab6067329..92f26d8e606 100644 --- a/app/(home)/layout.tsx +++ b/app/(home)/layout.tsx @@ -52,7 +52,7 @@ function RedirectIfNewUser() { const [authError, setAuthError] = useState(null); const [showModal, setShowModal] = useState(false); - // useEffect #1: Handle external token authentication + useEffect(() => { const fetchExternalToken = async () => { if (status !== "authenticated" || !session?.user?.email) return; @@ -61,8 +61,7 @@ function RedirectIfNewUser() { const externalToken = getCookie("access_token"); if (!externalToken) { - console.log("πŸ”΅ External token not found, obtaining..."); - + try { await axios.post( "/api/t1-token", @@ -76,7 +75,6 @@ function RedirectIfNewUser() { localStorage.removeItem("t1_token_error"); } } catch (error: any) { - console.error("❌ Failed to get external token:", error); if (error.response?.status === 404) { setAuthError("User not found in Ambassador DAO"); if (typeof window !== "undefined") { @@ -114,9 +112,6 @@ function RedirectIfNewUser() { if (typeof window !== "undefined") { localStorage.setItem("redirectAfterProfile", originalUrl); } - - // Show confirmation modal and redirect immediately - router.replace("/ambassador-dao/onboard"); setShowModal(true); } From 635c1263f3c89849b4d14575d7ed267e7b449de1 Mon Sep 17 00:00:00 2001 From: jhoan Date: Wed, 15 Oct 2025 19:24:45 -0500 Subject: [PATCH 07/12] Improve error handling and logging in login components - Enhanced error handling in FormLogin and VerifyEmail components by adding console error logging for OTP sending failures. - Updated state management in VerifyEmail to reset cooldown and retry attempts upon error. - Removed commented-out code in authOptions for cleaner implementation. --- components/login/FormLogin.tsx | 3 ++- components/login/verify/VerifyEmail.tsx | 5 ++++- lib/auth/authOptions.ts | 5 ----- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/components/login/FormLogin.tsx b/components/login/FormLogin.tsx index dbe12a63e13..e102d550a57 100644 --- a/components/login/FormLogin.tsx +++ b/components/login/FormLogin.tsx @@ -45,7 +45,8 @@ function Formlogin({ callbackUrl = "/" }: { callbackUrl?: string }) { }); setIsVerifying(true); } catch (error) { - formMethods.setError("email", { message: "Error sending OTP" }); + console.error("Error sending OTP", error); + setIsVerifying(true); } setIsLoading(false); diff --git a/components/login/verify/VerifyEmail.tsx b/components/login/verify/VerifyEmail.tsx index 6e940507be2..d0ee24f274b 100644 --- a/components/login/verify/VerifyEmail.tsx +++ b/components/login/verify/VerifyEmail.tsx @@ -110,7 +110,10 @@ export function VerifyEmail({ setExpired(false); setSentTries(0); } catch (error) { - setMessage("Error sending OTP. Please try again."); + setResendCooldown(60); + setExpired(false); + setSentTries(0); + console.error("Error sending OTP. Please try again.", error); } finally { setIsResending(false); } diff --git a/lib/auth/authOptions.ts b/lib/auth/authOptions.ts index 3b0789eae85..4f2f70f2350 100644 --- a/lib/auth/authOptions.ts +++ b/lib/auth/authOptions.ts @@ -100,11 +100,6 @@ export const AuthOptions: NextAuthOptions = { let user = await prisma.user.findUnique({ where: { email } }); if (!user) { - // user = await prisma.user.create({ - // data: { - // email, notification_email: email, name: '', image: '', last_login: null - // }, - // } user = { email, notification_email: email, name: '', image: '', last_login: new Date(), authentication_mode: '', bio: '', custom_attributes: [], id: '', integration: '', notifications: null, profile_privacy: null, social_media: [], telegram_user: '', user_name: '', created_at: new Date() From 5d4d3b65d2b3e2b808d1dae1b4c92e08b38d07ba Mon Sep 17 00:00:00 2001 From: jhoan Date: Mon, 20 Oct 2025 16:54:11 -0500 Subject: [PATCH 08/12] Refactor UserButton component to improve avatar display - Removed the default avatar import and replaced it with a UserRound icon for better visual representation. - Cleaned up the code for enhanced readability and maintainability. --- components/login/user-button/UserButton.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/components/login/user-button/UserButton.tsx b/components/login/user-button/UserButton.tsx index 3b2083c4a48..86c9b569342 100644 --- a/components/login/user-button/UserButton.tsx +++ b/components/login/user-button/UserButton.tsx @@ -14,7 +14,6 @@ import SignOutComponent from '../sign-out/SignOut'; import { useState } from 'react'; import { CircleUserRound, UserRound, UserCheck2, User2Icon, ListIcon, LogOut } from 'lucide-react'; import { Separator } from '@radix-ui/react-dropdown-menu'; -import DefaultAvatar from '@/public/ambassador-dao-images/Avatar.svg'; import { useRouter } from 'next/navigation'; export function UserButton() { const { data: session, status } = useSession() ?? {}; @@ -55,7 +54,7 @@ export function UserButton() { className='rounded-full' /> ) : ( - DefaultAvatar + )} From 5d709a48f932fc15e2da58818d257b8e96a2059e Mon Sep 17 00:00:00 2001 From: jhoan Date: Mon, 20 Oct 2025 17:53:54 -0500 Subject: [PATCH 09/12] Update TalentForm validation and submission logic - Adjusted username validation to enforce a length between 3 and 30 characters. - Enhanced social link validation by filtering out empty links before submission. - Set a default profile image URL if no image is provided during profile update. - Removed required attributes from several input fields for improved flexibility in form submission. --- app/(home)/ambassador-dao/onboard/page.tsx | 54 ++++++++++------------ 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/app/(home)/ambassador-dao/onboard/page.tsx b/app/(home)/ambassador-dao/onboard/page.tsx index 8b58bc3d080..04c75797dfa 100644 --- a/app/(home)/ambassador-dao/onboard/page.tsx +++ b/app/(home)/ambassador-dao/onboard/page.tsx @@ -308,7 +308,7 @@ const TalentForm = () => { ]); useEffect(() => { - if (username && username.length > 3) { + if (username && username.length >= 3 && username.length <= 30) { if (userData?.username === username) { setUsernameStatus("available"); return; @@ -374,24 +374,21 @@ const TalentForm = () => { }; const onSubmit = (data: any) => { - // Validate skills and social links manually - if (!selectedSkills.length) { - toast.error("Please select at least one skill"); - return; - } + // Filter out empty social links + const validSocialLinks = socialLinks.filter(link => link.trim() !== ''); - if (!socialLinks.length || !socialLinks.some(link => link.trim() !== "")) { - toast.error("Please add at least one social link"); - return; - } - - updateTalentProfile( - { - ...data, - skill_ids: selectedSkills, - social_links: socialLinks, - years_of_experience: +data.years_of_experience, - }, + // Set default profile image URL if no image is provided + const submitData = { + ...data, + profile_image: previewImage && previewImage.startsWith('data:image') + ? 'https://ava.com/profile.png' + : data.profile_image || 'https://ava.com/profile.png', + skill_ids: selectedSkills, + social_links: validSocialLinks, + years_of_experience: +data.years_of_experience, + }; + + updateTalentProfile(submitData, { onSuccess: async () => { // Save to local User table @@ -502,14 +499,14 @@ const TalentForm = () => { label="First Name" placeholder="First Name" error={errors.first_name} - {...register("first_name", { required: "First name is required" })} + {...register("first_name")} />
@@ -518,7 +515,7 @@ const TalentForm = () => { label="Bio" placeholder="Tell others about yourself in a few words" error={errors.bio} - {...register("bio", { required: "Bio is required" })} + {...register("bio")} />
@@ -527,7 +524,7 @@ const TalentForm = () => { label="Job Title" placeholder="Job Title" error={errors.job_title} - {...register("job_title", { required: "Job title is required" })} + {...register("job_title")} /> { type="number" placeholder="3" error={errors.years_of_experience} - {...register("years_of_experience", { required: "Years of experience is required" })} + {...register("years_of_experience")} />
@@ -545,7 +542,7 @@ const TalentForm = () => { label="User Name" placeholder="User Name" error={errors.username} - {...register("username", { required: "Username is required" })} + {...register("username")} className="relative" icon={ <> @@ -588,7 +585,7 @@ const TalentForm = () => { {countries.map((country, idx) => ( @@ -602,7 +599,6 @@ const TalentForm = () => {

Your skills - *

@@ -652,7 +648,6 @@ const TalentForm = () => { label={`Social Link ${idx + 1}`} placeholder="Enter social link" type="url" - required={idx === 0} value={link} onChange={(e) => { const updatedLinks = [...socialLinks]; @@ -790,7 +785,6 @@ const TalentForm = () => { id="wallet_address" label="Enter Wallet Address" placeholder="Enter Wallet Address" - required defaultValue={userData?.wallet_address || ""} {...register("wallet_address")} /> @@ -913,7 +907,7 @@ const SponsorForm = () => { }, [localProfileData, isEditProfilePage, setValue]); useEffect(() => { - if (username && username.length > 3) { + if (username && username.length >= 3 && username.length <= 30) { setUsernameStatus("checking"); const timer = setTimeout(() => { checkUsername(username, { @@ -937,7 +931,7 @@ const SponsorForm = () => { }, [username, checkUsername]); useEffect(() => { - if (company_username && company_username.length > 3) { + if (company_username && company_username.length >= 3 && company_username.length <= 30) { setCompanyUsernameStatus("checking"); const timer = setTimeout(() => { checkCompanyUsername(company_username, { From 404adfc9995aec4861ba5bfb0ba427dd54ae7dbb Mon Sep 17 00:00:00 2001 From: jhoan Date: Mon, 20 Oct 2025 18:03:13 -0500 Subject: [PATCH 10/12] Update redirect logic in RedirectIfNewUser component - Added condition to prevent redirection for users on the login page. - Enhanced user experience by ensuring new users are directed appropriately during onboarding. --- app/(home)/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(home)/layout.tsx b/app/(home)/layout.tsx index 92f26d8e606..8705b4b29ce 100644 --- a/app/(home)/layout.tsx +++ b/app/(home)/layout.tsx @@ -8,7 +8,6 @@ import { SessionProvider, useSession } from "next-auth/react"; import { useEffect, Suspense, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import axios from "axios"; -import { toast } from "sonner"; import Modal from "@/components/ui/Modal"; import { Button } from "@/components/ui/button"; @@ -103,6 +102,7 @@ function RedirectIfNewUser() { status === "authenticated" && session.user.is_new_user && pathname !== "/profile" && + pathname !== "/login" && pathname !== "/ambassador-dao/onboard" && errorLocalStorage != "" ) { From 95b188a8f8610d2f1bb8a4b83686511b14190490 Mon Sep 17 00:00:00 2001 From: jhoan Date: Tue, 21 Oct 2025 20:54:32 -0500 Subject: [PATCH 11/12] Refactor onboarding flow and user session handling - Updated the RedirectIfNewUser component to display a terms modal for new users instead of redirecting them immediately. - Enhanced the AmbassadorDao component to handle user session checks and redirect to the login page if the user is not authenticated when attempting to become a client. - Added an accepted_terms field to the user model in the database schema to track user agreement status. --- app/(home)/ambassador-dao/page.tsx | 14 +- app/(home)/layout.tsx | 14 +- components/login/terms.tsx | 196 ++++++++++++++++++ lib/auth/authOptions.ts | 2 +- .../migration.sql | 37 ++++ prisma/schema.prisma | 1 + 6 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 components/login/terms.tsx create mode 100644 prisma/migrations/20251022011449_add_accepted_terms/migration.sql diff --git a/app/(home)/ambassador-dao/page.tsx b/app/(home)/ambassador-dao/page.tsx index 77654a63483..1c0ca522492 100644 --- a/app/(home)/ambassador-dao/page.tsx +++ b/app/(home)/ambassador-dao/page.tsx @@ -12,6 +12,8 @@ import { useFetchUserDataQuery } from "@/services/ambassador-dao/requests/auth"; import FullScreenLoader from "@/components/ambassador-dao/full-screen-loader"; import { AmbassadorCard } from "@/components/ambassador-dao/dashboard/SideContent"; import { AuthModal } from "@/components/ambassador-dao/sections/auth-modal"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; const WelcomeSection = ({ user }: { user: any }) => { return ( @@ -71,10 +73,18 @@ const WelcomeSection = ({ user }: { user: any }) => { const AmbasssadorDao = () => { const { data: user, isLoading } = useFetchUserDataQuery(); const [openAuthModal, setOpenAuthModal] = useState(false); + const { data: session } = useSession(); + const router = useRouter(); if (isLoading) { return ; } - + const handleBecomeClient = () => { + if (!session) { + const currentUrl = window.location.pathname + window.location.search; + router.push(`/login?callbackUrl=${encodeURIComponent(currentUrl)}`); + + } + }; return (
@@ -97,7 +107,7 @@ const AmbasssadorDao = () => { setOpenAuthModal(true)} + onClick={handleBecomeClient} /> )}
diff --git a/app/(home)/layout.tsx b/app/(home)/layout.tsx index 8705b4b29ce..1ff21fe40c4 100644 --- a/app/(home)/layout.tsx +++ b/app/(home)/layout.tsx @@ -9,7 +9,7 @@ import { useEffect, Suspense, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import axios from "axios"; import Modal from "@/components/ui/Modal"; -import { Button } from "@/components/ui/button"; +import { Terms } from "@/components/login/terms"; export default function Layout({ children, @@ -112,7 +112,6 @@ function RedirectIfNewUser() { if (typeof window !== "undefined") { localStorage.setItem("redirectAfterProfile", originalUrl); } - router.replace("/ambassador-dao/onboard"); setShowModal(true); } }, [session, status, pathname, router, searchParams]); @@ -128,15 +127,8 @@ function RedirectIfNewUser() { className="border border-red-500" isOpen={showModal} onOpenChange={setShowModal} - title="Complete your profile" - description="Please fill your profile information to continue. This will help us provide you with a better experience." - footer={ -
- -
- } + title="" + content={} /> )} diff --git a/components/login/terms.tsx b/components/login/terms.tsx new file mode 100644 index 00000000000..9917b3279e2 --- /dev/null +++ b/components/login/terms.tsx @@ -0,0 +1,196 @@ +"use client" + +import React, { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Check, X, Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useToast } from "@/hooks/use-toast"; +import { useSession } from "next-auth/react"; +import Link from "next/link"; +import axios from "axios"; + +interface TermsProps { + userId: string; + onSuccess?: () => void; + onDecline?: () => void; +} + +export const Terms = ({ + userId, + onSuccess, + onDecline +}: TermsProps) => { + const [isAccepted, setIsAccepted] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const router = useRouter(); + const { toast } = useToast(); + const { update } = useSession(); + + const handleAccept = async () => { + setIsAccepted(true); + setIsSaving(true); + + try { + // Save only the notifications field + const dataToSave = { + notifications: true + }; + + // Save to API + await axios.put(`/api/profile/${userId}`, dataToSave); + + // Update session + await update(); + + // Show success message + toast({ + title: "Profile updated", + description: "Your profile has been updated successfully.", + }); + + // Execute success callback if provided + onSuccess?.(); + + // Navigate to home or redirect URL + const redirectUrl = typeof window !== "undefined" + ? localStorage.getItem("redirectAfterProfile") + : null; + + if (redirectUrl) { + localStorage.removeItem("redirectAfterProfile"); + router.push(redirectUrl); + } else { + router.push("/"); + } + } catch (error) { + console.error("Error saving profile:", error); + toast({ + title: "Error", + description: "An error occurred while saving the profile.", + variant: "destructive", + }); + // Reset state on error + setIsAccepted(null); + } finally { + setIsSaving(false); + } + }; + + const handleDecline = () => { + setIsAccepted(false); + // Call the parent callback to handle decline (e.g., close modal) + onDecline?.(); + }; + return ( + + + +

+ Review and agree to the terms to complete your registration. Please review our{" "} + + Avalanche Privacy Policy. + +

+
+ + + {/* Separator */} +
+ + {/* Terms Content */} +
+ {/* First checkbox - Event Participation */} +
+ setIsAccepted(true)} + /> +
+ + +
+
+ + {/* Second checkbox - Email Notifications */} +
+ setIsAccepted(true)} + /> +
+ +

+ Subscribe to newsletters and promotional materials. You can opt out anytime. +

+
+
+
+ + {/* Bottom Separator */} +
+
+ + +
+ + + +
+
+
+ ); +}; \ No newline at end of file diff --git a/lib/auth/authOptions.ts b/lib/auth/authOptions.ts index 4f2f70f2350..d5409584ccf 100644 --- a/lib/auth/authOptions.ts +++ b/lib/auth/authOptions.ts @@ -101,7 +101,7 @@ export const AuthOptions: NextAuthOptions = { let user = await prisma.user.findUnique({ where: { email } }); if (!user) { user = { - email, notification_email: email, name: '', image: '', last_login: new Date(), authentication_mode: '', bio: '', + email, notification_email: email, name: '', image: '', last_login: new Date(), authentication_mode: '', bio: '',accepted_terms: false, custom_attributes: [], id: '', integration: '', notifications: null, profile_privacy: null, social_media: [], telegram_user: '', user_name: '', created_at: new Date() } } diff --git a/prisma/migrations/20251022011449_add_accepted_terms/migration.sql b/prisma/migrations/20251022011449_add_accepted_terms/migration.sql new file mode 100644 index 00000000000..d5cfd8edf5f --- /dev/null +++ b/prisma/migrations/20251022011449_add_accepted_terms/migration.sql @@ -0,0 +1,37 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "accepted_terms" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "NodeRegistration" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "subnet_id" TEXT NOT NULL, + "blockchain_id" TEXT NOT NULL, + "node_id" TEXT NOT NULL, + "node_index" INTEGER, + "public_key" TEXT NOT NULL, + "proof_of_possession" TEXT NOT NULL, + "rpc_url" TEXT NOT NULL, + "chain_name" TEXT, + "status" TEXT NOT NULL DEFAULT 'active', + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "NodeRegistration_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "NodeRegistration_user_id_idx" ON "NodeRegistration"("user_id"); + +-- CreateIndex +CREATE INDEX "NodeRegistration_status_idx" ON "NodeRegistration"("status"); + +-- CreateIndex +CREATE INDEX "NodeRegistration_subnet_id_idx" ON "NodeRegistration"("subnet_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "NodeRegistration_user_id_subnet_id_node_index_key" ON "NodeRegistration"("user_id", "subnet_id", "node_index"); + +-- AddForeignKey +ALTER TABLE "NodeRegistration" ADD CONSTRAINT "NodeRegistration_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6dfe9633e82..6aa31386b0f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -45,6 +45,7 @@ model User { profile_privacy String? @default("public") social_media String[] notifications Boolean? + accepted_terms Boolean @default(false) custom_attributes String[] telegram_user String? created_at DateTime @default(now()) @db.Timestamptz(3) From 549ac1054a8a3779961050a6e64ff70bdc708b47 Mon Sep 17 00:00:00 2001 From: jhoan Date: Wed, 22 Oct 2025 09:00:17 -0500 Subject: [PATCH 12/12] Implement terms acceptance form with validation and update profile handling - Introduced a new terms acceptance form using react-hook-form and Zod for validation. - Updated the profile update logic to include the accepted_terms field. - Refactored the component structure for improved readability and user experience during the terms acceptance process. --- components/login/terms.tsx | 280 +++++++++++--------- components/login/user-button/UserButton.tsx | 1 + server/services/profile.ts | 1 + types/profile.ts | 1 + 4 files changed, 151 insertions(+), 132 deletions(-) diff --git a/components/login/terms.tsx b/components/login/terms.tsx index 9917b3279e2..ca3688350aa 100644 --- a/components/login/terms.tsx +++ b/components/login/terms.tsx @@ -1,15 +1,37 @@ "use client" -import React, { useState } from "react"; -import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"; +import React from "react"; +import { Card, CardContent, CardHeader, CardFooter } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; -import { Check, X, Loader2 } from "lucide-react"; +import { Check, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useToast } from "@/hooks/use-toast"; import { useSession } from "next-auth/react"; import Link from "next/link"; import axios from "axios"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormDescription, + FormMessage, +} from "@/components/ui/form"; + +// Form schema with validation +const termsFormSchema = z.object({ + accepted_terms: z.boolean().refine((val) => val === true, { + message: "You must accept the Terms and Conditions to continue.", + }), + notifications: z.boolean().default(false), +}); + +type TermsFormValues = z.infer; interface TermsProps { userId: string; @@ -22,34 +44,27 @@ export const Terms = ({ onSuccess, onDecline }: TermsProps) => { - const [isAccepted, setIsAccepted] = useState(null); - const [isSaving, setIsSaving] = useState(false); const router = useRouter(); const { toast } = useToast(); const { update } = useSession(); - const handleAccept = async () => { - setIsAccepted(true); - setIsSaving(true); - - try { - // Save only the notifications field - const dataToSave = { - notifications: true - }; + const form = useForm({ + resolver: zodResolver(termsFormSchema), + defaultValues: { + accepted_terms: false, + notifications: false, + }, + }); + const onSubmit = async (data: TermsFormValues) => { + try { // Save to API - await axios.put(`/api/profile/${userId}`, dataToSave); + await axios.put(`/api/profile/${userId}`, data); // Update session await update(); - // Show success message - toast({ - title: "Profile updated", - description: "Your profile has been updated successfully.", - }); - + // Execute success callback if provided onSuccess?.(); @@ -71,126 +86,127 @@ export const Terms = ({ description: "An error occurred while saving the profile.", variant: "destructive", }); - // Reset state on error - setIsAccepted(null); - } finally { - setIsSaving(false); } }; - const handleDecline = () => { - setIsAccepted(false); - // Call the parent callback to handle decline (e.g., close modal) - onDecline?.(); - }; return ( - - - -

- Review and agree to the terms to complete your registration. Please review our{" "} - - Avalanche Privacy Policy. - -

-
+
+ + + +

+ Review and agree to the terms to complete your registration. Please review our{" "} + + Avalanche Privacy Policy. + +

+
- - {/* Separator */} -
+ + {/* Separator */} +
- {/* Terms Content */} -
- {/* First checkbox - Event Participation */} -
- setIsAccepted(true)} - /> -
- - -
-
+ {/* Terms Content */} +
+ {/* First checkbox - Event Participation */} + ( + +
+ + + +
+ + I have read and agree to the Avalanche Privacy Policy{" "} + + Terms and Conditions, + + + +
+
+
+ )} + /> - {/* Second checkbox - Email Notifications */} -
- setIsAccepted(true)} - /> -
- -

- Subscribe to newsletters and promotional materials. You can opt out anytime. -

+ {/* Second checkbox - Email Notifications */} + ( + +
+ + + +
+ + I wish to stay informed about Avalanche news and events. + +

+ Subscribe to newsletters and promotional materials. You can opt out anytime. +

+
+
+
+ )} + />
-
-
- {/* Bottom Separator */} -
- + {/* Bottom Separator */} +
+ - -
- - - -
-
- + +
+ +
+
+ + + ); }; \ No newline at end of file diff --git a/components/login/user-button/UserButton.tsx b/components/login/user-button/UserButton.tsx index 86c9b569342..b88dac93835 100644 --- a/components/login/user-button/UserButton.tsx +++ b/components/login/user-button/UserButton.tsx @@ -15,6 +15,7 @@ import { useState } from 'react'; import { CircleUserRound, UserRound, UserCheck2, User2Icon, ListIcon, LogOut } from 'lucide-react'; import { Separator } from '@radix-ui/react-dropdown-menu'; import { useRouter } from 'next/navigation'; +import { cookies } from 'next/headers'; export function UserButton() { const { data: session, status } = useSession() ?? {}; const [isDialogOpen, setIsDialogOpen] = useState(false); diff --git a/server/services/profile.ts b/server/services/profile.ts index ca0db150a37..11219dba8be 100644 --- a/server/services/profile.ts +++ b/server/services/profile.ts @@ -57,6 +57,7 @@ export async function updateProfile(id: string, profileData: Partial) { notifications: data.notifications, profile_privacy: data.profile_privacy, social_media: data.social_media, + accepted_terms: data.accepted_terms, } }) diff --git a/types/profile.ts b/types/profile.ts index 04743641dac..afba21fc511 100644 --- a/types/profile.ts +++ b/types/profile.ts @@ -7,6 +7,7 @@ export type Profile = { image: string, social_media: string[], notifications: boolean | null, + accepted_terms: boolean, profile_privacy: string, telegram_user: string | undefined }