diff --git a/app/(home)/ambassador-dao/onboard/layout.tsx b/app/(home)/ambassador-dao/onboard/layout.tsx index b32e28c21a9..29423baada7 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."); + return; + } 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/onboard/page.tsx b/app/(home)/ambassador-dao/onboard/page.tsx index 1078bbdd009..04c75797dfa 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, @@ -191,6 +196,7 @@ const TalentForm = () => { setValue, watch, reset, + getValues, } = useForm({ defaultValues: { first_name: "", @@ -199,6 +205,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 +228,32 @@ 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, + getValues, + 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,10 +298,17 @@ const TalentForm = () => { setIsDataFetched(true); } - }, [userData, router, isEditProfilePage, isOnboardPage, reset]); + }, [ + userData, + localProfileData, + router, + isEditProfilePage, + isOnboardPage, + reset, + ]); useEffect(() => { - if (username && username.length > 3) { + if (username && username.length >= 3 && username.length <= 30) { if (userData?.username === username) { setUsernameStatus("available"); return; @@ -326,16 +369,35 @@ const TalentForm = () => { setValue("social_links", updated); }; + const handleSkip = () => { + onSkip(); + }; + const onSubmit = (data: any) => { - updateTalentProfile( - { - ...data, - skill_ids: selectedSkills, - social_links: socialLinks, - years_of_experience: +data.years_of_experience, - }, + // Filter out empty social links + const validSocialLinks = socialLinks.filter(link => link.trim() !== ''); + + // 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: () => { + onSuccess: async () => { + // Save to local User table + try { + await saveToLocalProfile(data, socialLinks); + } catch (error) { + // No bloqueamos el flujo + } + if (isEditProfilePage) { return; } else { @@ -352,8 +414,27 @@ const TalentForm = () => { wallet_address: data.wallet_address, }, { - onSuccess: () => { - router.push("/ambassador-dao"); + onSuccess: async () => { + // Update local User table with final data + try { + const formData = watch(); + await saveToLocalProfile(formData, socialLinks); + console.log("✅ Local profile updated with wallet"); + } catch (error) { + console.error("❌ Error updating local profile:", error); + } + + // 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"); + } }, } ); @@ -364,16 +445,16 @@ const TalentForm = () => { if (!isDataFetched) { return ( -
- +
+
); } return (
-
-

+
+

{isEditProfilePage ? stage === 1 ? "Edit Your Profile" @@ -383,136 +464,158 @@ 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,32 +623,31 @@ const TalentForm = () => { .map((badge, idx) => (
addSkill(badge.id)} > {badge.name} - +
))} {!skills?.length && ( <> -

+

No skills available

)}
-
+
{socialLinks.map((link, idx) => (
{ const updatedLinks = [...socialLinks]; @@ -554,10 +656,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 && ( + {!isEditProfilePage && ( setStage(2)} + onClick={handleSkip} + disabled={isUpdatingProfile} > - Edit Wallet + Skip - )} */} + )}
)} @@ -615,35 +779,34 @@ const TalentForm = () => { {stage === 2 && (
-
-
+
+
{isEditProfilePage ? "Update Wallet" : "Connect Wallet"} {isEditProfilePage && ( setStage(1)} > Back to Profile @@ -661,8 +824,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,8 +883,31 @@ const SponsorForm = () => { const pathname = usePathname(); const isEditProfilePage = pathname === "/ambassador-dao/edit-profile"; + // Fetch local profile data to get bio useEffect(() => { - if (username && username.length > 3) { + 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 && username.length <= 30) { setUsernameStatus("checking"); const timer = setTimeout(() => { checkUsername(username, { @@ -743,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, { @@ -849,7 +1037,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 +1065,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 +1167,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 +1263,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/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)/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..1ff21fe40c4 100644 --- a/app/(home)/layout.tsx +++ b/app/(home)/layout.tsx @@ -5,8 +5,11 @@ 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 Modal from "@/components/ui/Modal"; +import { Terms } from "@/components/login/terms"; export default function Layout({ children, @@ -26,26 +29,108 @@ 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); + const [showModal, setShowModal] = useState(false); + + + 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) { + + try { + await axios.post( + "/api/t1-token", + {}, + { + withCredentials: true, + } + ); + + if (typeof window !== "undefined") { + localStorage.removeItem("t1_token_error"); + } + } catch (error: any) { + if (error.response?.status === 404) { + setAuthError("User not found in Ambassador DAO"); + if (typeof window !== "undefined") { + localStorage.setItem("t1_token_error", "user_not_found"); + } + } else { + setAuthError("Failed to authenticate with Ambassador DAO"); + if (typeof window !== "undefined") { + localStorage.setItem("t1_token_error", "server_error"); + } + } + } + } else { + if (typeof window !== "undefined") { + localStorage.removeItem("t1_token_error"); + } + } + }; + + fetchExternalToken(); + }, [status, session?.user?.email]); useEffect(() => { + const errorLocalStorage = localStorage.getItem("t1_token_error"); if ( status === "authenticated" && session.user.is_new_user && - pathname !== "/profile" + pathname !== "/profile" && + pathname !== "/login" && + pathname !== "/ambassador-dao/onboard" && + errorLocalStorage != "" ) { - // Store the original URL with search params (including UTM) in localStorage - const originalUrl = `${pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + const originalUrl = `${pathname}${ + searchParams.toString() ? `?${searchParams.toString()}` : "" + }`; if (typeof window !== "undefined") { localStorage.setItem("redirectAfterProfile", originalUrl); } - router.replace("/profile"); + setShowModal(true); } }, [session, status, pathname, router, searchParams]); - return null; + const handleContinue = () => { + setShowModal(false); + }; + + return ( + <> + {showModal && ( + } + /> + )} + + ); } 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/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/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/components/login/terms.tsx b/components/login/terms.tsx new file mode 100644 index 00000000000..ca3688350aa --- /dev/null +++ b/components/login/terms.tsx @@ -0,0 +1,212 @@ +"use client" + +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, 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; + onSuccess?: () => void; + onDecline?: () => void; +} + +export const Terms = ({ + userId, + onSuccess, + onDecline +}: TermsProps) => { + const router = useRouter(); + const { toast } = useToast(); + const { update } = useSession(); + + 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}`, data); + + // Update session + await update(); + + + // 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", + }); + } + }; + + return ( + + + + +

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

+
+ + + {/* Separator */} +
+ + {/* Terms Content */} +
+ {/* First checkbox - Event Participation */} + ( + +
+ + + +
+ + I have read and agree to the Avalanche Privacy Policy{" "} + + Terms and Conditions, + + + +
+
+
+ )} + /> + + {/* 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 */} +
+
+ + +
+ +
+
+
+ + + ); +}; \ No newline at end of file diff --git a/components/login/user-button/UserButton.tsx b/components/login/user-button/UserButton.tsx index e66decb01dc..b88dac93835 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 { useRouter } from 'next/navigation'; +import { cookies } from 'next/headers'; 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' /> ) : ( - + )} @@ -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/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/hooks/useTalentProfile.ts b/hooks/useTalentProfile.ts new file mode 100644 index 00000000000..3d44e78766b --- /dev/null +++ b/hooks/useTalentProfile.ts @@ -0,0 +1,159 @@ +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 } 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; + getValues: UseFormGetValues; + setIsDataFetched: (value: boolean) => void; +} + +export const useTalentProfile = ({ + setValue, + watch, + getValues, + 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"); + + // 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, socialLinks?: string[]) => { + 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 - Only saves to local User table, NOT to Ambassador API + const onSkip = async () => { + // Save the current form data before skipping (no validation required) + setIsDataFetched(false); // Show loading + try { + const formData = getValues(); + + // Only save to local User table - save whatever data is available + await saveToLocalProfile(formData); + + toast.success("Profile saved successfully!"); + + // 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."); + } finally { + setIsDataFetched(true); + } + }; + + return { + // States + localProfileData, + previewImage, + isUploadingProfileImage, + profileImageName, + profileImageSize, + isUploading, + + // Functions + handleProfileImageUpload, + removeFile, + saveToLocalProfile, + onSkip, + }; +}; + diff --git a/lib/auth/authOptions.ts b/lib/auth/authOptions.ts index 3b0789eae85..d5409584ccf 100644 --- a/lib/auth/authOptions.ts +++ b/lib/auth/authOptions.ts @@ -100,13 +100,8 @@ 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: '', + 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/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/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) 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/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/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 { 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 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 }