diff --git a/package.json b/package.json index 548b55ed..5e3d64ea 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "redis": "^4.6.13", "square": "^34.0.1", "swr": "^2.2.5", + "usehooks-ts": "^2.14.0", "zod": "^3.22.4", "zustand": "^4.4.7" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa25db9c..560a906d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ dependencies: swr: specifier: ^2.2.5 version: 2.2.5(react@18.2.0) + usehooks-ts: + specifier: ^2.14.0 + version: 2.14.0(react@18.2.0) zod: specifier: ^3.22.4 version: 3.22.4 @@ -4255,6 +4258,10 @@ packages: p-locate: 5.0.0 dev: true + /lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + dev: false + /lodash.flatmap@4.5.0: resolution: {integrity: sha512-/OcpcAGWlrZyoHGeHh3cAoa6nGdX6QYtmzNP84Jqol6UEQQ2gIaU3H+0eICcjcKGl0/XF8LWOujNn9lffsnaOg==} dev: false @@ -6134,6 +6141,16 @@ packages: react: 18.2.0 dev: false + /usehooks-ts@2.14.0(react@18.2.0): + resolution: {integrity: sha512-jnhrjTRJoJS7cFxz63tRYc5mzTKf/h+Ii8P0PDHymT9qDe4ZA2/gzDRmDR4WGausg5X8wMIdghwi3BBCN9JKow==} + engines: {node: '>=16.15.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + dependencies: + lodash.debounce: 4.0.8 + react: 18.2.0 + dev: false + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true diff --git a/src/components/Header.tsx b/src/components/Header.tsx deleted file mode 100644 index ea3e8824..00000000 --- a/src/components/Header.tsx +++ /dev/null @@ -1,194 +0,0 @@ -'use client'; - -import FancyRectangle from '@/components/FancyRectangle'; -import { BREAKPOINTS } from '@/constants/breakpoints'; -import { useMount } from '@/hooks/use-mount'; -import { fetcher } from '@/lib/fetcher'; -import { useUser } from '@clerk/nextjs'; -import Image from 'next/image'; -import Link from 'next/link'; -import { useState } from 'react'; -import { IoMdClose, IoMdMenu } from 'react-icons/io'; -import useSWR from 'swr'; -import Button from './Button'; -import UserButton from './UserButton'; - -export default function Header() { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const toggleMenu = () => { - setIsMenuOpen(!isMenuOpen); - }; - const closeMenu = () => { - setIsMenuOpen(false); - }; - - const clerkUser = useUser(); - const checkUserExists = useSWR<{ exists: boolean }>(['user-existence'], fetcher.get.query, { - isPaused: () => clerkUser.isLoaded && !clerkUser.isSignedIn, - }); - - const checkUserPaid = useSWR<{ paid: boolean }>(['payment'], fetcher.get.query, { - isPaused: () => clerkUser.isLoaded && !clerkUser.isSignedIn, - }); - - const [isScrolled, setIsScrolled] = useState(false); - useMount(() => { - window.addEventListener('scroll', handleScroll); - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('scroll', handleScroll); - window.removeEventListener('resize', handleResize); - }; - }); - const handleResize = () => { - if (window.innerWidth >= BREAKPOINTS.md) { - setIsMenuOpen(false); - } - }; - const handleScroll = () => { - const scrollPosition = window.scrollY; - setIsScrolled(scrollPosition > 0); - }; - - const isLoading = !clerkUser.isLoaded || checkUserExists.isLoading; - return ( -
-
- -
-
-
-
- - Computer Science Club Logo -

- CS CLUB -

- - -
- - - -
-
-
-
- - -
- -
-
-
- ); -} diff --git a/src/components/Header/HeaderClient.tsx b/src/components/Header/HeaderClient.tsx new file mode 100644 index 00000000..552cdb6d --- /dev/null +++ b/src/components/Header/HeaderClient.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useClerk } from '@clerk/clerk-react'; +import { Transition } from '@headlessui/react'; +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; +import { useRef, useState } from 'react'; +import { useOnClickOutside } from 'usehooks-ts'; +import type { HeaderData } from '.'; +import Button from '../Button'; +import FancyRectangle from '../FancyRectangle'; +import { Links, MenuLinks } from './components/Links'; +import LogoTitle from './components/LogoTitle'; +import ScrollShader from './components/ScrollShader'; +import SignInJoin from './components/SignInJoin'; + +function UserButton({ data }: { data: HeaderData }) { + const [isMenuOpen, setMenuOpen] = useState(false); + const ref = useRef(null); + const closeMenu = () => { + setMenuOpen(false); + }; + useOnClickOutside(ref, closeMenu); + const handleButtonClick = () => { + setMenuOpen(!isMenuOpen); + }; + + const { signOut } = useClerk(); + const router = useRouter(); + const handleSignOut = async () => { + await signOut(); + router.push('/'); + router.refresh(); + }; + + const userExists = data.nextStep !== 'signup'; + return ( + +
+ + +
+ {userExists && } + +
+
+
+
+ ); +} + +export default function HeaderClient({ + data, + className, +}: { + data: HeaderData; + className?: string; +}) { + return ( +
+ +
+
+ + + {data.nextStep === 'signup' && ( + + )} + {data.nextStep === 'payment' && ( + + )} + {data.isSignedIn ? : } +
+
+
+
+ ); +} diff --git a/src/components/Header/HeaderMobileClient.tsx b/src/components/Header/HeaderMobileClient.tsx new file mode 100644 index 00000000..64d1dd33 --- /dev/null +++ b/src/components/Header/HeaderMobileClient.tsx @@ -0,0 +1,93 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; +import { IoMdClose, IoMdMenu } from 'react-icons/io'; +import type { HeaderData } from '.'; +import FancyRectangle from '../FancyRectangle'; +import { Links, MenuLinks } from './components/Links'; +import LogoTitle from './components/LogoTitle'; +import ScrollShader from './components/ScrollShader'; + +export default function HeaderMobileClient({ + data, + className, +}: { + data: HeaderData; + className?: string; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const toggleMenu = () => { + setIsMenuOpen(!isMenuOpen); + }; + const closeMenu = () => { + setIsMenuOpen(false); + }; + + return ( +
+ +
+
+ + + + +
+ {isMenuOpen && ( + <> +
+
+ +
+ {data.nextStep === 'signup' && ( + + Continue Signing Up + + )} + {data.nextStep === 'payment' && ( + + Continue to payment + + )} + +
+ + )} +
+
+ ); +} diff --git a/src/components/Header/components/Links.tsx b/src/components/Header/components/Links.tsx new file mode 100644 index 00000000..232cde8f --- /dev/null +++ b/src/components/Header/components/Links.tsx @@ -0,0 +1,48 @@ +import { env } from '@/env.mjs'; +import Link from 'next/link'; +import type { HeaderData } from '..'; + +export function MenuLinks({ data, onClick }: { data: HeaderData; onClick?: () => void }) { + const isMember = data.nextStep === null; + return ( + <> + {isMember && ( + + CS Club Drive + + )} + + Settings + + {data.isAdmin && ( + + Admin Panel + + )} + + ); +} + +const LINKS = ['about', 'events', 'sponsors', 'contact']; +export function Links({ onClick }: { onClick?: () => void }) { + return ( + <> + {LINKS.map((link, i) => ( + + {link} + + ))} + + ); +} diff --git a/src/components/Header/components/LogoTitle.tsx b/src/components/Header/components/LogoTitle.tsx new file mode 100644 index 00000000..c669daca --- /dev/null +++ b/src/components/Header/components/LogoTitle.tsx @@ -0,0 +1,27 @@ +import Image from 'next/image'; +import Link from 'next/link'; + +export default function LogoTitle({ + titleColor, + className, + onClick, +}: { + titleColor: 'text-grey' | 'text-white'; + className?: string; + onClick?: () => void; +}) { + return ( + + Computer Science Club Logo +

+ CS CLUB +

+ + ); +} diff --git a/src/components/Header/components/ScrollShader.tsx b/src/components/Header/components/ScrollShader.tsx new file mode 100644 index 00000000..463984fe --- /dev/null +++ b/src/components/Header/components/ScrollShader.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { useState } from 'react'; +import { useEventListener } from 'usehooks-ts'; + +export default function ScrollShader({ className }: { className?: string }) { + const [isScrolled, setIsScrolled] = useState(false); + useEventListener('scroll', () => { + const scrollPosition = window.scrollY; + setIsScrolled(scrollPosition > 0); + }); + + return ( +
+ ); +} diff --git a/src/components/Header/components/SignInJoin.tsx b/src/components/Header/components/SignInJoin.tsx new file mode 100644 index 00000000..51daa3f2 --- /dev/null +++ b/src/components/Header/components/SignInJoin.tsx @@ -0,0 +1,14 @@ +import Button from '../../Button'; + +export default function SignInJoin() { + return ( + <> + + + + ); +} diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx new file mode 100644 index 00000000..5b96a22e --- /dev/null +++ b/src/components/Header/index.tsx @@ -0,0 +1,43 @@ +import { checkUserExists } from '@/server/check-user-exists'; +import { verifyMembershipPayment } from '@/server/verify-membership-payment'; +import { currentUser } from '@clerk/nextjs'; +import HeaderClient from './HeaderClient'; +import HeaderMobileClient from './HeaderMobileClient'; + +const getHeaderData = async () => { + const user = await currentUser(); + if (!user) { + return { isSignedIn: false as const }; + } + + let nextStep: 'signup' | 'payment' | null = null; + const exists = await checkUserExists(user.id); + if (exists) { + const membershipPayment = await verifyMembershipPayment(user.id); + if (!membershipPayment.paid) { + nextStep = 'payment'; + } + } else { + nextStep = 'signup'; + } + + return { + isSignedIn: true as const, + avatar: user.imageUrl, + isAdmin: (user.publicMetadata.isAdmin as boolean | undefined) ?? false, + nextStep, + isMember: nextStep === null, + }; +}; +export type HeaderData = Awaited>; + +export default async function Header() { + const headerData = await getHeaderData(); + + return ( + <> + + + + ); +} diff --git a/src/components/UserButton.tsx b/src/components/UserButton.tsx deleted file mode 100644 index e5c80281..00000000 --- a/src/components/UserButton.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import Button from '@/components/Button'; -import { env } from '@/env.mjs'; -import { useClerk, useUser } from '@clerk/clerk-react'; -import Image from 'next/image'; -import Link from 'next/link'; -import { useState } from 'react'; -import FancyRectangle from './FancyRectangle'; - -export default function UserButton({ - userExists, - userPaid, -}: { - userExists: boolean; - userPaid: boolean; -}) { - const { user } = useUser(); - const [isPopupOpen, setPopupOpen] = useState(false); - const { signOut } = useClerk(); - - const handleButtonClick = () => { - setPopupOpen(!isPopupOpen); - }; - - const handleSignOut = async () => { - await signOut(); - }; - - if (!user) return <>; - - return ( - -
- - - {/* Popup menu */} - {isPopupOpen && ( -
- {/* Only show settings if finished sign up and show drive link if membership paid */} - {userExists && ( - <> - {userPaid && ( - - CS Club Drive - - )} - - Settings - - {user.publicMetadata.isAdmin && ( - - Admin Panel - - )} - - )} - {/* Sign Out */} - -
- )} -
-
- ); -}