diff --git a/components/console/builder-hub-account-button.tsx b/components/console/builder-hub-account-button.tsx index cd5035bb5cf..a8954675ab5 100644 --- a/components/console/builder-hub-account-button.tsx +++ b/components/console/builder-hub-account-button.tsx @@ -3,15 +3,16 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; import { CircleUserRound, LogOut, User } from "lucide-react"; -import { useState } from "react"; import { useSession, signOut } from "next-auth/react"; import { useRouter } from "next/navigation"; import Image from "next/image"; +import { useLoginModalTrigger } from "@/hooks/useLoginModal"; +import { LoginModal } from "@/components/login/LoginModal"; export function BuilderHubAccountButton() { const { data: session, status } = useSession(); const router = useRouter(); - const [isSignOutDialogOpen, setIsSignOutDialogOpen] = useState(false); + const { openLoginModal } = useLoginModalTrigger(); const isAuthenticated = status === 'authenticated'; const isLoading = status === 'loading'; @@ -29,13 +30,13 @@ export function BuilderHubAccountButton() { }); } - signOut({ callbackUrl: '/login' }); + signOut({ redirect: false }); }; const handleLoginClick = () => { // Store current URL as callback for after login const currentUrl = window.location.href; - router.push(`/login?callbackUrl=${encodeURIComponent(currentUrl)}`); + openLoginModal(currentUrl); }; if (isLoading) { @@ -51,45 +52,50 @@ export function BuilderHubAccountButton() { ); } - return (isAuthenticated ? ( - - - - - - - {session?.user?.email || 'No email available'} - - - {session?.user?.name || 'No name available'} - - - router.push('/profile')}> - - Profile - - - - Sign Out - - - - ) : ( - - )); + return ( + <> + + {isAuthenticated ? ( + + + + + + + {session?.user?.email || 'No email available'} + + + {session?.user?.name || 'No name available'} + + + router.push('/profile')}> + + Profile + + + + Sign Out + + + + ) : ( + + )} + + ); } \ No newline at end of file diff --git a/components/login/LoginModal.tsx b/components/login/LoginModal.tsx new file mode 100644 index 00000000000..79dbcb87e91 --- /dev/null +++ b/components/login/LoginModal.tsx @@ -0,0 +1,168 @@ +"use client"; + +import React, { useState, useEffect } from 'react'; +import Image from "next/image"; +import Link from "next/link"; +import { useForm, Controller } from 'react-hook-form'; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import axios from "axios"; +import { Dialog, DialogOverlay, DialogContent, DialogTitle } from '../toolbox/components/ui/dialog'; +import { Input } from "../ui/input"; +import { LoadingButton } from "../ui/loading-button"; +import SocialLogin from "./social-login/SocialLogin"; +import { VerifyEmail } from "./verify/VerifyEmail"; +import { useLoginModalState } from '@/hooks/useLoginModal'; + +const formSchema = z.object({ + email: z.string().email("Please enter a valid email address"), +}); + +export function LoginModal() { + const { isOpen, callbackUrl = "/", closeLoginModal, subscribeToChanges } = useLoginModalState(); + const [isVerifying, setIsVerifying] = useState(false); + const [email, setEmail] = useState(""); + + const { control, handleSubmit, setError, reset, formState: { errors, isSubmitting } } = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { email: "" }, + }); + + // Subscribe to modal state changes + useEffect(() => { + return subscribeToChanges(); + }, [subscribeToChanges]); + + // Reset form when modal opens/closes + useEffect(() => { + if (!isOpen) { + setIsVerifying(false); + setEmail(""); + reset(); + } + }, [isOpen, reset]); + + async function onSubmit(values: z.infer) { + setEmail(values.email); + + try { + await axios.post("/api/send-otp", { + email: values.email.toLowerCase(), + }); + setIsVerifying(true); + } catch (error) { + setError("email", { message: "Error sending OTP" }); + } + } + + if (!isOpen) return null; + + return ( + + + + + {/* Compact Header - Full Width */} +
+
+
+ Avalanche +
+
+ + {/* Form Content - No Side Padding */} + {isVerifying && email ? ( +
+ setIsVerifying(false)} + callbackUrl={callbackUrl} + /> +
+ ) : ( +
+
+ {/* Title */} +
+ + Sign in to your account + +

+ Enter your email to receive a sign-in code +

+
+ + {/* Form */} +
+ ( +
+ + {errors.email && ( +

+ {errors.email.message} +

+ )} +
+ )} + /> + + Continue with Email + + + + {/* Social Login */} + + + {/* Footer */} +
+

+ By signing in, you agree to our{" "} + + Terms + {" "} + and{" "} + + Privacy Policy + +

+
+
+
+ )} +
+
+
+ ); +} + diff --git a/components/login/index.ts b/components/login/index.ts new file mode 100644 index 00000000000..3df4c51c849 --- /dev/null +++ b/components/login/index.ts @@ -0,0 +1,4 @@ +export { LoginModal } from './LoginModal'; +export { default as FormLogin } from './FormLogin'; +export { VerifyEmail } from './verify/VerifyEmail'; + diff --git a/components/login/social-login/SocialLogin.tsx b/components/login/social-login/SocialLogin.tsx index d4d48be0e18..f8f33926ebf 100644 --- a/components/login/social-login/SocialLogin.tsx +++ b/components/login/social-login/SocialLogin.tsx @@ -1,7 +1,7 @@ import { signIn } from "next-auth/react"; import React from "react"; -import { Separator } from "@/components/ui/separator"; -import SocialLoginButton from "./SocialLoginButton"; +import Image from "next/image"; +import { Button } from "@/components/ui/button"; import { SocialLoginProps } from "@/types/socialLoginProps"; function SocialLogin({ callbackUrl = "/" }: SocialLoginProps) { @@ -10,31 +10,63 @@ function SocialLogin({ callbackUrl = "/" }: SocialLoginProps) { } return ( -
-
- - - SIGN IN WITH - - +
+ {/* Divider */} +
+
+
+
+
+ + Or + +
-
- + + +
); diff --git a/components/login/social-login/SocialLoginButton.tsx b/components/login/social-login/SocialLoginButton.tsx index 5096a36f032..4c61b6177f9 100644 --- a/components/login/social-login/SocialLoginButton.tsx +++ b/components/login/social-login/SocialLoginButton.tsx @@ -6,17 +6,17 @@ function SocialLoginButton({ name, image, onClick }: { name: string; image: stri return ( ); } diff --git a/components/toolbox/components/ui/dialog.tsx b/components/toolbox/components/ui/dialog.tsx index 427289ff740..56def46e847 100644 --- a/components/toolbox/components/ui/dialog.tsx +++ b/components/toolbox/components/ui/dialog.tsx @@ -1,6 +1,7 @@ import React, { useEffect } from 'react'; import * as RadixDialog from "@radix-ui/react-dialog"; import { X } from 'lucide-react'; +import { cn } from '@/lib/cn'; // Custom Dialog Root component that handles pointer-events cleanup interface DialogRootProps { @@ -59,12 +60,15 @@ interface DialogContentProps extends React.ComponentProps = ({ children, showCloseButton = true, - className = "", + className, ...props }) => { return ( {children} @@ -92,12 +96,12 @@ export const DialogOverlay: React.FC> = ({ - className = "", + className, ...props }) => { return ( ); diff --git a/hooks/useLoginModal.ts b/hooks/useLoginModal.ts new file mode 100644 index 00000000000..56f87b4f40d --- /dev/null +++ b/hooks/useLoginModal.ts @@ -0,0 +1,64 @@ +'use client'; + +import { useState, useCallback } from 'react'; + +interface LoginModalState { + isOpen: boolean; + callbackUrl?: string; +} + +let globalLoginModalState: LoginModalState = { + isOpen: false, + callbackUrl: undefined, +}; + +const loginModalListeners = new Set<() => void>(); + +const notifyLoginModalChange = () => { + loginModalListeners.forEach(listener => listener()); +}; + +// Hook for components that need to trigger the login modal +export function useLoginModalTrigger() { + const openLoginModal = useCallback((callbackUrl?: string) => { + globalLoginModalState = { + isOpen: true, + callbackUrl, + }; + notifyLoginModalChange(); + }, []); + + return { + openLoginModal, + }; +} + +// Hook for the LoginModal component to manage its state +export function useLoginModalState() { + const [, forceUpdate] = useState({}); + + // Subscribe to modal state changes + const subscribeToChanges = useCallback(() => { + const listener = () => forceUpdate({}); + loginModalListeners.add(listener); + return () => { + loginModalListeners.delete(listener); + }; + }, []); + + const closeLoginModal = useCallback(() => { + globalLoginModalState = { + isOpen: false, + callbackUrl: undefined, + }; + notifyLoginModalChange(); + }, []); + + return { + isOpen: globalLoginModalState.isOpen, + callbackUrl: globalLoginModalState.callbackUrl, + closeLoginModal, + subscribeToChanges, + }; +} + diff --git a/public/brands/github.svg b/public/brands/github.svg new file mode 100644 index 00000000000..9d3891a8f97 --- /dev/null +++ b/public/brands/github.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/brands/google.svg b/public/brands/google.svg new file mode 100644 index 00000000000..fb4871514f4 --- /dev/null +++ b/public/brands/google.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/brands/x.svg b/public/brands/x.svg new file mode 100644 index 00000000000..e9d4f684652 --- /dev/null +++ b/public/brands/x.svg @@ -0,0 +1,4 @@ + + + +