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/app/(account)/forgot-password/page.tsx b/src/app/(account)/forgot-password/page.tsx index 48f7ee82..203968ea 100644 --- a/src/app/(account)/forgot-password/page.tsx +++ b/src/app/(account)/forgot-password/page.tsx @@ -11,6 +11,11 @@ import { z } from 'zod'; import { handleClerkErrors } from '../helpers'; import { codeSchema, emailSchema, passwordSchema } from '../schemas'; +// export const metadata: Metadata = { +// title: 'Forgot Password', +// robots: { index: false, follow: false }, +// }; + const sendCodeSchema = z.object({ email: emailSchema, }); diff --git a/src/app/(account)/join/page.tsx b/src/app/(account)/join/page.tsx index e3a81d6a..6147cb90 100644 --- a/src/app/(account)/join/page.tsx +++ b/src/app/(account)/join/page.tsx @@ -12,7 +12,11 @@ import StepThree from './steps/StepThree'; import StepTwo from './steps/StepTwo'; import { useJoinUsHeading, useJoinUsStep } from './store'; -export default function JoinUsPage() { +// export const metadata: Metadata = { +// title: 'Join', +// }; + +export default function JoinPage() { const { step, setStep } = useJoinUsStep(); const { heading } = useJoinUsHeading(); diff --git a/src/app/(account)/join/steps/StepFour.tsx b/src/app/(account)/join/steps/StepFour.tsx index 8f74defa..a32dc9ff 100644 --- a/src/app/(account)/join/steps/StepFour.tsx +++ b/src/app/(account)/join/steps/StepFour.tsx @@ -26,6 +26,7 @@ export default function StepFour() { }, onSuccess: () => { router.push('/settings'); + router.refresh(); }, }); diff --git a/src/app/(account)/join/steps/StepOne.tsx b/src/app/(account)/join/steps/StepOne.tsx index 9d13d0fe..d4a710fd 100644 --- a/src/app/(account)/join/steps/StepOne.tsx +++ b/src/app/(account)/join/steps/StepOne.tsx @@ -3,6 +3,7 @@ import ControlledField from '@/components/ControlledField'; import { useSignUp } from '@clerk/nextjs'; import { zodResolver } from '@hookform/resolvers/zod'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { FcGoogle } from 'react-icons/fc'; @@ -33,6 +34,7 @@ function VerifyEmail() { const [verifyEmailLoading, setVerifyEmailLoading] = useState(false); + const router = useRouter(); const handleVerify = form.handleSubmit(async ({ code }) => { if (!isLoaded) return; @@ -48,6 +50,7 @@ function VerifyEmail() { return; } await setActive({ session: completeSignUp.createdSessionId }); + router.refresh(); } catch (error) { handleClerkErrors(error, form, [ { code: 'form_param_nil', field: 'code', message: 'Please enter the the code.' }, diff --git a/src/app/(account)/settings/page.tsx b/src/app/(account)/settings/page.tsx index f6e92e0c..ea1c6e11 100644 --- a/src/app/(account)/settings/page.tsx +++ b/src/app/(account)/settings/page.tsx @@ -3,10 +3,16 @@ import Title from '@/components/Title'; import { checkUserExists } from '@/server/check-user-exists'; import { verifyMembershipPayment } from '@/server/verify-membership-payment'; import { currentUser } from '@clerk/nextjs'; +import type { Metadata } from 'next'; import Link from 'next/link'; import { notFound } from 'next/navigation'; import Settings from './Settings'; +export const metadata: Metadata = { + title: 'Settings', + robots: { index: false, follow: false }, +}; + export default async function SettingsPage() { const user = await currentUser(); if (!user) return notFound(); diff --git a/src/app/(account)/settings/tabs/MembershipSettings.tsx b/src/app/(account)/settings/tabs/MembershipSettings.tsx index dadb894f..e257bed9 100644 --- a/src/app/(account)/settings/tabs/MembershipSettings.tsx +++ b/src/app/(account)/settings/tabs/MembershipSettings.tsx @@ -52,7 +52,12 @@ export default function MembershipSettings({

Pay Membership Fee

- diff --git a/src/app/(account)/signin/page.tsx b/src/app/(account)/signin/page.tsx index cfe5d786..2540ef35 100644 --- a/src/app/(account)/signin/page.tsx +++ b/src/app/(account)/signin/page.tsx @@ -6,6 +6,7 @@ import FancyRectangle from '@/components/FancyRectangle'; import { useSignIn } from '@clerk/clerk-react'; import { zodResolver } from '@hookform/resolvers/zod'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { FcGoogle } from 'react-icons/fc'; @@ -13,6 +14,10 @@ import { z } from 'zod'; import { handleClerkErrors } from '../helpers'; import { emailSchema } from '../schemas'; +// export const metadata: Metadata = { +// title: 'Sign In', +// }; + const signInSchema = z.object({ email: emailSchema, password: z.string().min(1, { message: 'Please enter your password' }), @@ -28,6 +33,7 @@ export default function SignInPage() { const [signInLoading, setSignInLoading] = useState(false); + const router = useRouter(); const handleSignIn = form.handleSubmit(async ({ email, password }) => { if (!isLoaded) return; @@ -41,7 +47,8 @@ export default function SignInPage() { if (result.status === 'complete') { await setActive({ session: result.createdSessionId }); - location.href = '/'; + router.push('/'); + router.refresh(); } else { console.log(result); } diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 39809bd4..e4a3532e 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -4,10 +4,15 @@ import Paragraph from '@/components/Paragraph'; import Title from '@/components/Title'; import { COMMITTEE_MEMBERS } from '@/data/committee-members'; import { LINKS } from '@/data/links'; +import type { Metadata } from 'next'; import Image from 'next/image'; import FAQ from './FAQ'; -export default function AboutUs() { +export const metadata: Metadata = { + title: 'About', +}; + +export default function AboutPage() { return (
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 234e69b3..bd092c6c 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2,9 +2,15 @@ import FancyRectangle from '@/components/FancyRectangle'; import Title from '@/components/Title'; import { db } from '@/db'; import { currentUser } from '@clerk/nextjs'; +import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import MemberForm from './MemberForm'; +export const metadata: Metadata = { + title: 'Admin Panel', + robots: { index: false, follow: false }, +}; + const queryMembers = async () => { const dbMembers = await db.query.memberTable.findMany({ columns: { diff --git a/src/app/api/payment/route.ts b/src/app/api/payment/route.ts index aac9da3c..2f000969 100644 --- a/src/app/api/payment/route.ts +++ b/src/app/api/payment/route.ts @@ -11,7 +11,6 @@ import { env } from '@/env.mjs'; import { redisClient } from '@/lib/redis'; import { squareClient } from '@/lib/square'; import { updateMemberExpiryDate } from '@/server/update-member-expiry-date'; -import { verifyMembershipPayment } from '@/server/verify-membership-payment'; import { currentUser } from '@clerk/nextjs'; import { eq } from 'drizzle-orm'; import type { CreatePaymentLinkRequest } from 'square'; @@ -115,14 +114,3 @@ export async function PUT(request: Request) { } return Response.json({ success: true }); } - -// Get membership payment status -export async function GET() { - const user = await currentUser(); - if (!user) { - return new Response(null, { status: 401 }); - } - - const { paid } = await verifyMembershipPayment(user.id); - return Response.json({ paid }); -} diff --git a/src/app/api/user-existence/route.ts b/src/app/api/user-existence/route.ts deleted file mode 100644 index 26a0a228..00000000 --- a/src/app/api/user-existence/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { checkUserExists } from '@/server/check-user-exists'; -import { currentUser } from '@clerk/nextjs'; - -export async function GET() { - const user = await currentUser(); - if (!user) { - return new Response(null, { status: 401 }); - } - - const exists = await checkUserExists(user.id); - return Response.json({ exists }); -} diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx index 01c4bdbc..ad4be49b 100644 --- a/src/app/contact/page.tsx +++ b/src/app/contact/page.tsx @@ -1,8 +1,13 @@ import Title from '@/components/Title'; +import type { Metadata } from 'next'; import Contact from './Contact'; import Form from './Form'; import Sponsorship from './Sponsorship'; +export const metadata: Metadata = { + title: 'Contact', +}; + export default function ContactPage() { return (
diff --git a/src/app/events/page.tsx b/src/app/events/page.tsx index 6e5d255a..2795476d 100644 --- a/src/app/events/page.tsx +++ b/src/app/events/page.tsx @@ -1,8 +1,13 @@ +import type { Metadata } from 'next'; import Image from 'next/image'; import Events from './Events'; import FridayNight from './FridayNight'; import Info from './Info'; +export const metadata: Metadata = { + title: 'Events', +}; + export default function EventsPage() { return (
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 967ea949..c99d36dc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,7 +7,10 @@ import { Archivo } from 'next/font/google'; export const metadata: Metadata = { icons: '/favicon.ico', - title: 'Computer Science Club', + title: { + template: '%s | Computer Science Club', + default: 'Computer Science Club', + }, description: 'The University of Adelaide Computer Science Club is a student-run club for those with an interest in computer science or computing in general.', }; diff --git a/src/app/sponsors/page.tsx b/src/app/sponsors/page.tsx index f13e25da..233ec244 100644 --- a/src/app/sponsors/page.tsx +++ b/src/app/sponsors/page.tsx @@ -3,6 +3,11 @@ import Duck from '@/components/Duck'; import Paragraph from '@/components/Paragraph'; import Title from '@/components/Title'; import { YEAR } from '@/data/sponsors'; +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Sponsors', +}; export default function SponsorsPage() { return ( 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 */} - -
- )} -
-
- ); -}