From 2123259a2a1e4d885b2b743cd093b9d8a12f4d5b Mon Sep 17 00:00:00 2001 From: Jacob Ellerbrock <113381905+jacobellerbrock@users.noreply.github.com> Date: Mon, 21 Oct 2024 19:18:13 -0500 Subject: [PATCH 1/6] Merging in new schedule enhancements (#128) * schedule timeline initial styling * Timeline days added * add live pulsing effect * add little line * Bigger day headers * run prettier --------- Co-authored-by: joshuasilva414 --- apps/web/src/app/dash/schedule/page.tsx | 20 ++- .../app/dash/schedule/schedule-timeline.tsx | 150 ++++++++++++++++++ apps/web/src/app/globals.css | 44 +++++ 3 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/app/dash/schedule/schedule-timeline.tsx diff --git a/apps/web/src/app/dash/schedule/page.tsx b/apps/web/src/app/dash/schedule/page.tsx index 1d9b20fc..b2b059c3 100644 --- a/apps/web/src/app/dash/schedule/page.tsx +++ b/apps/web/src/app/dash/schedule/page.tsx @@ -1,11 +1,23 @@ import { Suspense } from "react"; import UserScheduleView from "@/components/schedule/UserScheduleView"; +import ScheduleTimeline from "./schedule-timeline"; import Loading from "@/components/shared/Loading"; -export default function Page() { +import { getAllEvents } from "db/functions"; +import { headers } from "next/headers"; +import { VERCEL_IP_TIMEZONE_HEADER_KEY } from "@/lib/constants"; +import { getClientTimeZone } from "@/lib/utils/client/shared"; +export default async function Page() { + const sched = await getAllEvents(); + const userTimeZoneHeaderKey = headers().get(VERCEL_IP_TIMEZONE_HEADER_KEY); + const userTimeZone = getClientTimeZone(userTimeZoneHeaderKey); return ( - }> - - + <> +

Schedule

+ }> + {/* */} + + + ); } diff --git a/apps/web/src/app/dash/schedule/schedule-timeline.tsx b/apps/web/src/app/dash/schedule/schedule-timeline.tsx new file mode 100644 index 00000000..6737ad25 --- /dev/null +++ b/apps/web/src/app/dash/schedule/schedule-timeline.tsx @@ -0,0 +1,150 @@ +"use client"; +import { Badge } from "@/components/shadcn/ui/badge"; +import { type EventType as Event } from "@/lib/types/events"; +import { cn } from "@/lib/utils/client/cn"; +import c from "config"; +import { formatInTimeZone } from "date-fns-tz"; +import Link from "next/link"; +import { ReactNode } from "react"; + +const daysOfWeek = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; + +function splitByDay(schedule: Event[]) { + const days: Map = new Map(); + schedule.forEach((event) => { + const day = daysOfWeek[event.startTime.getDay()]; + if (days.get(day)) { + days.get(day)?.push(event); + } else { + days.set(day, [event]); + } + }); + return days; +} + +type ScheduleTimelineProps = { + schedule: Event[]; + timezone: string; +}; +export default function ScheduleTimeline({ + schedule, + timezone, +}: ScheduleTimelineProps) { + return ( +
+ + + {Array.from(splitByDay(schedule).entries()).map( + ([dayName, arr]): ReactNode => ( + <> + + + + + + {arr?.map( + (event): ReactNode => ( + + ), + )} + + ), + )} + +
+

+ {dayName} +

+
+
+ ); +} + +type EventRowProps = { event: Event; userTimeZone: string }; +export function EventRow({ event, userTimeZone }: EventRowProps) { + const startTimeFormatted = formatInTimeZone( + event.startTime, + userTimeZone, + "hh:mm a", + { + useAdditionalDayOfYearTokens: true, + }, + ); + + const endTimeFormatted = formatInTimeZone( + event.endTime, + userTimeZone, + "h:mm a", + ); + + const currentTime = new Date(); + const isLive = event.startTime < currentTime && event.endTime > currentTime; + + const href = `/schedule/${event.id}`; + const color = (c.eventTypes as Record)[event.type]; + return ( + + + {`${startTimeFormatted} - ${endTimeFormatted}`} + + {isLive ? ( +
+ ) : ( +
+
+
+ )} + + +
+ {event.title}{" "} + +

{event.type}

+
+
+ + + + ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 645edc25..8675b928 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -82,6 +82,7 @@ * { @apply border-border; } + body { @apply bg-background text-foreground; } @@ -104,3 +105,46 @@ .event-pass-img { transform: translateZ(40px); } + +.pulsatingDot { + /* animation: pulseDot 1.25s cubic-bezier(0.455, 0.03, 0.515, 0.955) -.4s infinite; */ + /* border-radius: 50%; */ + /* box-sizing: border-box; */ + transform-origin: center; +} + +.pulsatingDot:before { + animation: pulseRing 1.25s cubic-bezier(0.215, 0.61, 0.355, 1) infinite; + /* background-color: var(--pulsating-dot, #00BEFF); */ + background-color: hsl(var(--hackathon-primary)); + border-radius: 45px; + content: ""; + display: block; + height: 300%; + left: -100%; + position: relative; + top: -100%; + width: 300%; +} + +@keyframes pulseRing { + 0% { + transform: scale(0.5); + } + + 80%, + 100% { + opacity: 0; + } +} + +@keyframes pulseDot { + 0%, + 100% { + transform: scale(1); + } + + 50% { + transform: scale(1.1); + } +} From 638fd5738a7b40da9f10013aaed19d4d64003f3b Mon Sep 17 00:00:00 2001 From: Christian Walker <76548772+christianhelp@users.noreply.github.com> Date: Thu, 24 Oct 2024 00:34:03 -0500 Subject: [PATCH 2/6] Fix Hackathon Check-In Scanner (#130) --- .../actions/admin/scanner-admin-actions.ts | 22 +- apps/web/src/app/admin/check-in/page.tsx | 4 +- .../admin/scanner/CheckinScanner.tsx | 203 +++++++++--------- .../src/components/shared/ProfileButton.tsx | 1 + apps/web/src/lib/constants/index.ts | 1 + packages/config/hackkit.config.ts | 2 +- 6 files changed, 128 insertions(+), 105 deletions(-) diff --git a/apps/web/src/actions/admin/scanner-admin-actions.ts b/apps/web/src/actions/admin/scanner-admin-actions.ts index fb5d46a2..af21fd0e 100644 --- a/apps/web/src/actions/admin/scanner-admin-actions.ts +++ b/apps/web/src/actions/admin/scanner-admin-actions.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { db, sql } from "db"; import { scans, userCommonData } from "db/schema"; import { eq, and } from "db/drizzle"; + export const createScan = adminAction .schema( z.object({ @@ -65,12 +66,23 @@ export const getScan = adminAction }, ); -export const checkInUser = adminAction - .schema(z.string()) - .action(async ({ parsedInput: user }) => { +// Schema will be moved over when rewrite of the other scanner happens +const checkInUserSchema = z.object({ + userID: z.string(), + QRTimestamp: z + .number() + .positive() + .refine((timestamp) => { + return Date.now() - timestamp < 5 * 60 * 1000; + }, "QR Code has expired. Please tell user refresh the QR Code"), +}); + +export const checkInUserToHackathon = adminAction + .schema(checkInUserSchema) + .action(async ({ parsedInput: { userID } }) => { // Set checkinTimestamp - return await db + await db .update(userCommonData) .set({ checkinTimestamp: sql`now()` }) - .where(eq(userCommonData.clerkID, user)); + .where(eq(userCommonData.clerkID, userID)); }); diff --git a/apps/web/src/app/admin/check-in/page.tsx b/apps/web/src/app/admin/check-in/page.tsx index 0dea1ab8..af117b82 100644 --- a/apps/web/src/app/admin/check-in/page.tsx +++ b/apps/web/src/app/admin/check-in/page.tsx @@ -19,7 +19,8 @@ export default async function Page({ ); const scanUser = await getUser(searchParams.user); - if (!scanUser) + console.log(scanUser); + if (!scanUser) { return (
); + } return (
diff --git a/apps/web/src/components/admin/scanner/CheckinScanner.tsx b/apps/web/src/components/admin/scanner/CheckinScanner.tsx index d13c2be4..525ad748 100644 --- a/apps/web/src/components/admin/scanner/CheckinScanner.tsx +++ b/apps/web/src/components/admin/scanner/CheckinScanner.tsx @@ -3,11 +3,11 @@ import { useState, useEffect } from "react"; import { QrScanner } from "@yudiel/react-qr-scanner"; import superjson from "superjson"; -import { checkInUser } from "@/actions/admin/scanner-admin-actions"; -import { useAction } from "next-safe-action/hooks"; +import { checkInUserToHackathon } from "@/actions/admin/scanner-admin-actions"; import { type QRDataInterface } from "@/lib/utils/shared/qr"; import type { User } from "db/types"; - +import clsx from "clsx"; +import { useAction } from "next-safe-action/hooks"; import { Drawer, DrawerContent, @@ -19,16 +19,8 @@ import { import { Button } from "@/components/shadcn/ui/button"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { toast } from "sonner"; - -/* - -Pass Scanner Props: - -eventName: name of the event that the user is scanning into -hasScanned: if the state has eventered one in which a QR has been scanned (whether that scan has scanned before or not) -scan: the scan object that has been scanned. If they have not scanned before scan will be null leading to a new record or if they have then it will incriment the scan count. - -*/ +import { FIVE_MINUTES_IN_MILLISECONDS } from "@/lib/constants"; +import { ValidationErrors } from "next-safe-action"; interface CheckinScannerProps { hasScanned: boolean; @@ -43,9 +35,9 @@ export default function CheckinScanner({ scanUser, hasRSVP, }: CheckinScannerProps) { + console.log("scanner props is: ", hasScanned, checkedIn, scanUser, hasRSVP); + const [scanLoading, setScanLoading] = useState(false); - // const { execute: runScanAction } = useAction(checkInUser, {}); - const [proceed, setProceed] = useState(hasRSVP); useEffect(() => { if (hasScanned) { setScanLoading(false); @@ -56,22 +48,83 @@ export default function CheckinScanner({ const path = usePathname(); const router = useRouter(); + function handleUseActionFeedback(hasErrored = false, message = "") { + console.log("called"); + toast.dismiss(); + hasErrored + ? toast.error(message || "Failed to Check User Into Hackathon") + : toast.success( + message || "Successfully Checked User Into Hackathon!", + ); + router.replace(`${path}`); + } + + const { execute: runCheckInUserToHackathon } = useAction( + checkInUserToHackathon, + { + onSuccess: () => { + handleUseActionFeedback(); + }, + onError: ({ error, input }) => { + console.log("error is: ", error); + console.log("input is: ", input); + if (error.validationErrors?.QRTimestamp?._errors) { + handleUseActionFeedback( + true, + error.validationErrors.QRTimestamp._errors[0], + ); + } else { + handleUseActionFeedback(true); + } + }, + }, + ); + function handleScanCreate() { const params = new URLSearchParams(searchParams.toString()); const timestamp = parseInt(params.get("createdAt") as string); + + if (!scanUser) { + return alert("User Not Found"); + } + if (isNaN(timestamp)) { return alert("Invalid QR Code Data (Field: createdAt)"); } + if (Date.now() - timestamp > FIVE_MINUTES_IN_MILLISECONDS) { + return alert( + "QR Code has expired. Please tell user to refresh the QR Code", + ); + } + if (checkedIn) { return alert("User Already Checked in!"); } else { - // TODO: make this a little more typesafe - checkInUser(scanUser?.clerkID!); + toast.loading("Checking User In"); + runCheckInUserToHackathon({ + userID: scanUser.clerkID, + QRTimestamp: timestamp, + }); } - toast.success("Successfully Scanned User In"); - router.replace(`${path}`); + router.replace(path); } + const drawerTitle = checkedIn + ? "User Already Checked In" + : !hasRSVP + ? "Warning!" + : "New Scan"; + const drawerDescription = checkedIn + ? "If this is a mistake, please talk to an admin" + : !hasRSVP + ? `${scanUser?.firstName} ${scanUser?.lastName} Is not RSVP'd` + : `New scan for ${scanUser?.firstName} ${scanUser?.lastName}`; + const drawerFooterButtonText = checkedIn + ? "Close" + : !hasRSVP + ? "Check In Anyways" + : "Scan User In"; + return ( <>
@@ -108,11 +161,6 @@ export default function CheckinScanner({ }} />
- {/*
- - - -
*/}
Loading Scan... - {/* */} - - - - ) : ( - <> - - New Scan - - - New scan for{" "} - {scanUser?.firstName}{" "} - {scanUser?.lastName} - - - )} - - )} + + {drawerTitle} + - {proceed ? ( - <> - - {!checkedIn && ( - - )} - - - - ) : ( - <> - )} + + {drawerDescription} + + + {!hasRSVP && !checkedIn && ( +
+ Do you wish to proceed? +
+ )} + {!checkedIn && ( + + )} + +
)} diff --git a/apps/web/src/components/shared/ProfileButton.tsx b/apps/web/src/components/shared/ProfileButton.tsx index 80479377..86410e51 100644 --- a/apps/web/src/components/shared/ProfileButton.tsx +++ b/apps/web/src/components/shared/ProfileButton.tsx @@ -19,6 +19,7 @@ import { DropdownSwitcher } from "@/components/shared/ThemeSwitcher"; import DefaultDropdownTrigger from "../dash/shared/DefaultDropDownTrigger"; import MobileNavBarLinks from "./MobileNavBarLinks"; import { getUser } from "db/functions"; +import { redirect } from "next/navigation"; export default async function ProfileButton() { const clerkUser = await auth(); diff --git a/apps/web/src/lib/constants/index.ts b/apps/web/src/lib/constants/index.ts index b239662a..f34ca64c 100644 --- a/apps/web/src/lib/constants/index.ts +++ b/apps/web/src/lib/constants/index.ts @@ -1,2 +1,3 @@ export const ONE_HOUR_IN_MILLISECONDS = 3600000; +export const FIVE_MINUTES_IN_MILLISECONDS = 300000; export const VERCEL_IP_TIMEZONE_HEADER_KEY = "x-vercel-ip-timezone"; diff --git a/packages/config/hackkit.config.ts b/packages/config/hackkit.config.ts index 58fa86b0..5c5f93ff 100644 --- a/packages/config/hackkit.config.ts +++ b/packages/config/hackkit.config.ts @@ -852,7 +852,7 @@ const c = { Users: "/admin/users", Events: "/admin/events", Points: "/admin/points", - "Check-in": "/admin/check-in", + "Hackathon Check-in": "/admin/check-in", Toggles: "/admin/toggles", }, // TODO: Can remove days? Pretty sure they're dynamic now. From 11c560247ebdb870a47034236ca1ff537c42b9c2 Mon Sep 17 00:00:00 2001 From: Christian Walker Date: Thu, 24 Oct 2024 13:07:27 -0500 Subject: [PATCH 3/6] fix admin link check --- apps/web/src/components/shared/ProfileButton.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/shared/ProfileButton.tsx b/apps/web/src/components/shared/ProfileButton.tsx index 86410e51..c9afb89a 100644 --- a/apps/web/src/components/shared/ProfileButton.tsx +++ b/apps/web/src/components/shared/ProfileButton.tsx @@ -111,7 +111,6 @@ export default async function ProfileButton() { ); } - // Returns only if there is a full user return ( @@ -153,14 +152,13 @@ export default async function ProfileButton() { Event Pass - {user.role === "admin" || - (user.role === "super_admin" && ( + {['admin','super_admin','volunteer'].includes(user.role) && ( Admin - ))} + )} From 19d324cde4df3a22e79402cd094345d73fa373a2 Mon Sep 17 00:00:00 2001 From: Jacob Ellerbrock <113381905+jacobellerbrock@users.noreply.github.com> Date: Thu, 24 Oct 2024 14:15:06 -0500 Subject: [PATCH 4/6] Satisfies User Settings w/ New Schema (#112) --- .gitignore | 3 + apps/web/src/actions/user-profile-mod.ts | 187 ++- apps/web/src/app/settings/account/page.tsx | 15 - apps/web/src/app/settings/layout.tsx | 15 +- apps/web/src/app/settings/page.tsx | 33 +- apps/web/src/app/settings/profile/page.tsx | 19 - .../src/app/settings/registration/page.tsx | 15 + .../components/registration/RegisterForm.tsx | 4 - .../components/settings/AccountSettings.tsx | 142 ++- .../components/settings/ProfileSettings.tsx | 163 ++- .../RegistrationForm/RegisterFormSettings.tsx | 1076 +++++++++++++++++ .../settings/RegistrationSettings.tsx | 41 + .../components/settings/SettingsSection.tsx | 1 + .../src/components/shared/ProfileButton.tsx | 16 +- .../shared/RegistrationSettingsForm.ts | 130 ++ packages/config/hackkit.config.ts | 2 +- packages/db/functions/user.ts | 12 +- packages/db/types.ts | 1 + 18 files changed, 1731 insertions(+), 144 deletions(-) delete mode 100644 apps/web/src/app/settings/account/page.tsx delete mode 100644 apps/web/src/app/settings/profile/page.tsx create mode 100644 apps/web/src/app/settings/registration/page.tsx create mode 100644 apps/web/src/components/settings/RegistrationForm/RegisterFormSettings.tsx create mode 100644 apps/web/src/components/settings/RegistrationSettings.tsx create mode 100644 apps/web/src/validators/shared/RegistrationSettingsForm.ts diff --git a/.gitignore b/.gitignore index 9f67afc6..adc80bcb 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ yarn-error.log* # vscode .vscode + +#Jetbrians +.idea \ No newline at end of file diff --git a/apps/web/src/actions/user-profile-mod.ts b/apps/web/src/actions/user-profile-mod.ts index 66693bce..1afb8131 100644 --- a/apps/web/src/actions/user-profile-mod.ts +++ b/apps/web/src/actions/user-profile-mod.ts @@ -3,54 +3,198 @@ import { authenticatedAction } from "@/lib/safe-action"; import { z } from "zod"; import { db } from "db"; -import { userCommonData } from "db/schema"; +import { userCommonData, userHackerData } from "db/schema"; import { eq } from "db/drizzle"; import { put } from "@vercel/blob"; import { decodeBase64AsFile } from "@/lib/utils/shared/files"; import { returnValidationErrors } from "next-safe-action"; import { revalidatePath } from "next/cache"; -import { getUser } from "db/functions"; +import { getUser, getUserByTag } from "db/functions"; +import { RegistrationSettingsFormValidator } from "@/validators/shared/RegistrationSettingsForm"; -// TODO: Add skill updating export const modifyRegistrationData = authenticatedAction + .schema(RegistrationSettingsFormValidator) + .action( + async ({ + parsedInput: { + age, + gender, + race, + ethnicity, + isEmailable, + university, + major, + levelOfStudy, + schoolID, + hackathonsAttended, + softwareBuildingExperience, + heardAboutEvent, + shirtSize, + dietaryRestrictions, + accommodationNote, + github, + linkedin, + personalWebsite, + phoneNumber, + countryOfResidence, + }, + ctx: { userId }, + }) => { + const user = await getUser(userId); + if (!user) throw new Error("User not found"); + await Promise.all([ + db + .update(userCommonData) + .set({ + age, + gender, + race, + ethnicity, + shirtSize, + dietRestrictions: dietaryRestrictions, + accommodationNote, + phoneNumber, + countryOfResidence, + }) + .where(eq(userCommonData.clerkID, user.clerkID)), + db + .update(userHackerData) + .set({ + isEmailable, + university, + major, + levelOfStudy, + schoolID, + hackathonsAttended, + softwareExperience: softwareBuildingExperience, + heardFrom: heardAboutEvent, + GitHub: github, + LinkedIn: linkedin, + PersonalWebsite: personalWebsite, + }) + .where(eq(userHackerData.clerkID, user.clerkID)), + ]); + return { + success: true, + newAge: age, + newGender: gender, + newRace: race, + newEthnicity: ethnicity, + newWantsToReceiveMLHEmails: isEmailable, + newUniversity: university, + newMajor: major, + newLevelOfStudy: levelOfStudy, + newSchoolID: schoolID, + newHackathonsAttended: hackathonsAttended, + newSoftwareExperience: softwareBuildingExperience, + newHeardFrom: heardAboutEvent, + newShirtSize: shirtSize, + newDietaryRestrictions: dietaryRestrictions, + newAccommodationNote: accommodationNote, + newGitHub: github, + newLinkedIn: linkedin, + newPersonalWebsite: personalWebsite, + newPhoneNumber: phoneNumber, + newCountryOfResidence: countryOfResidence, + }; + }, + ); + +export const modifyResume = authenticatedAction .schema( z.object({ - bio: z.string().max(500), - skills: z.string().max(100), + resume: z.string(), }), ) - .action(async ({ parsedInput: { bio, skills }, ctx: { userId } }) => { - const user = await getUser(userId); - if (!user) - returnValidationErrors(z.null(), { _errors: ["User not found"] }); - + .action(async ({ parsedInput: { resume }, ctx: { userId } }) => { await db - .update(userCommonData) - .set({ bio }) - .where(eq(userCommonData.clerkID, user.clerkID)); - return { success: true, newbio: bio }; + .update(userHackerData) + .set({ resume }) + .where(eq(userHackerData.clerkID, userId)); + return { + success: true, + newResume: resume, + }; }); +export const modifyProfileData = authenticatedAction + .schema( + z.object({ + pronouns: z.string(), + bio: z.string(), + skills: z.string().array(), + discord: z.string(), + }), + ) + .action( + async ({ + parsedInput: { bio, discord, pronouns, skills }, + ctx: { userId }, + }) => { + const user = await getUser(userId); + if (!user) { + throw new Error("User not found"); + } + await db + .update(userCommonData) + .set({ pronouns, bio, skills, discord }) + .where(eq(userCommonData.clerkID, user.clerkID)); + return { + success: true, + newPronouns: pronouns, + newBio: bio, + newSkills: skills, + newDiscord: discord, + }; + }, + ); + +// TODO: Fix after registration enhancements to allow for failure on conflict and return appropriate error message export const modifyAccountSettings = authenticatedAction .schema( z.object({ firstName: z.string().min(1).max(50), lastName: z.string().min(1).max(50), + hackerTag: z.string().min(1).max(50), + hasSearchableProfile: z.boolean(), }), ) .action( - async ({ parsedInput: { firstName, lastName }, ctx: { userId } }) => { + async ({ + parsedInput: { + firstName, + lastName, + hackerTag, + hasSearchableProfile, + }, + ctx: { userId }, + }) => { const user = await getUser(userId); if (!user) throw new Error("User not found"); - + let oldHackerTag = user.hackerTag; // change when hackertag is not PK on profileData table + if (oldHackerTag != hackerTag) + if (await getUserByTag(hackerTag)) + //if hackertag changed + // copied from /api/registration/create + return { + success: false, + message: "hackertag_not_unique", + }; await db .update(userCommonData) - .set({ firstName, lastName }) + .set({ + firstName, + lastName, + hackerTag, + isSearchable: hasSearchableProfile, + }) .where(eq(userCommonData.clerkID, userId)); return { success: true, newFirstName: firstName, newLastName: lastName, + newHackerTag: hackerTag, + newHasSearchableProfile: hasSearchableProfile, }; }, ); @@ -60,11 +204,10 @@ export const updateProfileImage = authenticatedAction .action( async ({ parsedInput: { fileBase64, fileName }, ctx: { userId } }) => { const image = await decodeBase64AsFile(fileBase64, fileName); - const user = await getUser(userId); - if (!user) - returnValidationErrors(z.null(), { - _errors: ["User not found"], - }); + const user = await db.query.userCommonData.findFirst({ + where: eq(userCommonData.clerkID, userId), + }); + if (!user) throw new Error("User not found"); const blobUpload = await put(image.name, image, { access: "public", diff --git a/apps/web/src/app/settings/account/page.tsx b/apps/web/src/app/settings/account/page.tsx deleted file mode 100644 index fbfdd1e0..00000000 --- a/apps/web/src/app/settings/account/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import AccountSettings from "@/components/settings/AccountSettings"; -import { auth } from "@clerk/nextjs"; -import { redirect } from "next/navigation"; -import { getHacker } from "db/functions"; - -export default async function Page() { - const { userId } = auth(); - if (!userId) return redirect("/sign-in"); - - const user = await getHacker(userId, false); - if (!user) return redirect("/sign-in"); - return ; -} - -export const runtime = "edge"; diff --git a/apps/web/src/app/settings/layout.tsx b/apps/web/src/app/settings/layout.tsx index f0497ab5..560f9dd9 100644 --- a/apps/web/src/app/settings/layout.tsx +++ b/apps/web/src/app/settings/layout.tsx @@ -34,12 +34,15 @@ export default async function ({ children }: { children: ReactNode }) { -
- {/* */} - - -
-
{children}
+ +
{children}
); diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index 9c4722c4..685c8251 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -1,3 +1,32 @@ -export default function Page() { - return howdy; +import AccountSettings from "@/components/settings/AccountSettings"; +import { auth } from "@clerk/nextjs"; +import { redirect } from "next/navigation"; +import ProfileSettings from "@/components/settings/ProfileSettings"; +import RegistrationSettings from "@/components/settings/RegistrationSettings"; +import { getUser } from "db/functions"; + +export default async function Page() { + const { userId } = auth(); + if (!userId) return redirect("/sign-in"); + const user = await getUser(userId); + if (!user) return redirect("/sign-in"); + + return ( +
+
+ +
+ +
+ +
+ ); +} + +function Header({ tag }: { tag: string }) { + return ( +

+ {tag} +

+ ); } diff --git a/apps/web/src/app/settings/profile/page.tsx b/apps/web/src/app/settings/profile/page.tsx deleted file mode 100644 index 06e9a82d..00000000 --- a/apps/web/src/app/settings/profile/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import ProfileSettings from "@/components/settings/ProfileSettings"; -import { auth } from "@clerk/nextjs"; -import { getHacker } from "db/functions"; - -export default async function Page() { - const { userId } = auth(); - if (!userId) throw new Error("User not found"); - - const user = await getHacker(userId, false); - if (!user) throw new Error("User not found"); - return ( - - ); -} - -export const runtime = "edge"; diff --git a/apps/web/src/app/settings/registration/page.tsx b/apps/web/src/app/settings/registration/page.tsx new file mode 100644 index 00000000..ba11a134 --- /dev/null +++ b/apps/web/src/app/settings/registration/page.tsx @@ -0,0 +1,15 @@ +import { auth } from "@clerk/nextjs"; +import { redirect } from "next/navigation"; +import RegistrationFormSettings from "@/components/settings/RegistrationForm/RegisterFormSettings"; +import { getHackerData, getUser } from "db/functions"; + +export default async function Page() { + const { userId } = auth(); + if (!userId) return redirect("/sign-in"); + const user = await getUser(userId); + if (!user) return redirect("/sign-in"); + const hackerData = await getHackerData(userId); + if (!hackerData) return redirect("/sign-in"); + + return ; +} diff --git a/apps/web/src/components/registration/RegisterForm.tsx b/apps/web/src/components/registration/RegisterForm.tsx index 85feddfe..1658a8ff 100644 --- a/apps/web/src/components/registration/RegisterForm.tsx +++ b/apps/web/src/components/registration/RegisterForm.tsx @@ -116,10 +116,6 @@ export default function RegisterForm({ defaultEmail }: RegisterFormProps) { } }, [universityValue]); - useEffect(() => { - console.log(countryValue); - }, [countryValue]); - async function onSubmit(data: z.infer) { console.log(data); setIsLoading(true); diff --git a/apps/web/src/components/settings/AccountSettings.tsx b/apps/web/src/components/settings/AccountSettings.tsx index f05151f9..9e693dad 100644 --- a/apps/web/src/components/settings/AccountSettings.tsx +++ b/apps/web/src/components/settings/AccountSettings.tsx @@ -7,32 +7,47 @@ import { toast } from "sonner"; import { useState } from "react"; import { useAction } from "next-safe-action/hooks"; import { modifyAccountSettings } from "@/actions/user-profile-mod"; +import { Checkbox } from "@/components/shadcn/ui/checkbox"; +import c from "config"; +import { Loader2 } from "lucide-react"; +import { isProfane } from "no-profanity"; interface UserProps { firstName: string; lastName: string; -} -interface AccountSettingsProps { - user: UserProps; + email: string; + hackerTag: string; + isSearchable: boolean; } -export default function AccountSettings({ user }: AccountSettingsProps) { +export default function AccountSettings({ user }: { user: UserProps }) { const [newFirstName, setNewFirstName] = useState(user.firstName); const [newLastName, setNewLastName] = useState(user.lastName); + //const [newEmail, setNewEmail] = useState(user.email); + const [newHackerTag, setNewHackerTag] = useState(user.hackerTag); + const [newIsProfileSearchable, setNewIsProfileSearchable] = useState( + user.isSearchable, + ); + const [hackerTagTakenAlert, setHackerTagTakenAlert] = useState(false); - const { execute: runModifyAccountSettings } = useAction( - modifyAccountSettings, - { - onSuccess: () => { + const { execute: runModifyAccountSettings, status: loadingState } = + useAction(modifyAccountSettings, { + onSuccess: ({ data }) => { toast.dismiss(); - toast.success("Name updated successfully!"); + if (!data?.success) { + if (data?.message == "hackertag_not_unique") { + toast.error("Hackertag already exists"); + setHackerTagTakenAlert(true); + } + } else toast.success("Account updated successfully!"); }, onError: () => { toast.dismiss(); - toast.error("An error occurred while updating your name!"); + toast.error( + "An error occurred while updating your account settings!", + ); }, - }, - ); + }); return (
@@ -40,7 +55,7 @@ export default function AccountSettings({ user }: AccountSettingsProps) {

Personal Information

-
+
setNewFirstName(e.target.value)} /> + {!newFirstName ? ( +
+ This field can't be empty! +
+ ) : null}
- + setNewLastName(e.target.value)} /> + {!newLastName ? ( +
+ This field can't be empty! +
+ ) : null}
-
+

+ Public Information +

+
+
+ +
+
+ @ +
+ { + setNewHackerTag(e.target.value); + setHackerTagTakenAlert(false); + }} + /> +
+ {hackerTagTakenAlert ? ( +
+ HackerTag is already taken! +
+ ) : ( + "" + )} + {!newHackerTag ? ( +
+ This field can't be empty! +
+ ) : null} +
+
- Update - + + setNewIsProfileSearchable( + !newIsProfileSearchable, + ) + } + /> + +
+
); diff --git a/apps/web/src/components/settings/ProfileSettings.tsx b/apps/web/src/components/settings/ProfileSettings.tsx index 792a6a3b..8f455aaa 100644 --- a/apps/web/src/components/settings/ProfileSettings.tsx +++ b/apps/web/src/components/settings/ProfileSettings.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/shadcn/ui/button"; import { Label } from "@/components/shadcn/ui/label"; import { Textarea } from "@/components/shadcn/ui/textarea"; import { - modifyRegistrationData, + modifyProfileData, updateProfileImage, } from "@/actions/user-profile-mod"; import { useUser } from "@clerk/nextjs"; @@ -13,41 +13,72 @@ import { useAction } from "next-safe-action/hooks"; import { toast } from "sonner"; import { useState } from "react"; import { encodeFileAsBase64 } from "@/lib/utils/shared/files"; +import { Tag, TagInput } from "@/components/shadcn/ui/tag/tag-input"; +import { Loader2 } from "lucide-react"; +import { Avatar, AvatarImage } from "@/components/shadcn/ui/avatar"; -interface ProfileSettingsProps { +interface ProfileData { + pronouns: string; bio: string; - university: string; + skills: string[]; + discord: string | null; + profilePhoto: string; } -export default function ProfileSettings({ - bio, - university, -}: ProfileSettingsProps) { - const [newBio, setNewBio] = useState(bio); - const [newUniversity, setNewUniversity] = useState(university); + +interface ProfileSettingsProps { + profile: ProfileData; +} + +export default function ProfileSettings({ profile }: ProfileSettingsProps) { + const [newPronouns, setNewPronouns] = useState(profile.pronouns); + const [newBio, setNewBio] = useState(profile.bio); const [newProfileImage, setNewProfileImage] = useState(null); + let curSkills: Tag[] = []; + // for (let i = 0; i < profile.skills.length; i++) { + // let t: Tag = { + // id: profile.skills[i], + // text: profile.skills[i], + // }; + // curSkills.push(t); + // } + profile.skills.map((skill) => { + curSkills.push({ + id: skill, + text: skill, + }); + }); + const [newSkills, setNewSkills] = useState(curSkills); + const [newDiscord, setNewDiscord] = useState(profile.discord || ""); + + const [isProfilePictureLoading, setIsProfilePictureLoading] = + useState(false); + const [isProfileSettingsLoading, setIsProfileSettingsLoading] = + useState(false); + const { user } = useUser(); - const { execute: runModifyRegistrationData } = useAction( - modifyRegistrationData, - { - onSuccess: () => { - toast.dismiss(); - toast.success("Profile updated successfully!"); - }, - onError: () => { - toast.dismiss(); - toast.error("An error occurred while updating your profile!"); - }, + const { execute: runModifyProfileData } = useAction(modifyProfileData, { + onSuccess: () => { + setIsProfileSettingsLoading(false); + toast.dismiss(); + toast.success("Profile updated successfully!"); }, - ); + onError: () => { + setIsProfileSettingsLoading(false); + toast.dismiss(); + toast.error("An error occurred while updating your profile!"); + }, + }); const { execute: runUpdateProfileImage } = useAction(updateProfileImage, { onSuccess: async () => { + setIsProfilePictureLoading(false); toast.dismiss(); await user?.setProfileImage({ file: newProfileImage }); toast.success("Profile Photo updated successfully!"); }, onError: (err) => { + setIsProfilePictureLoading(false); toast.dismiss(); toast.error("An error occurred while updating your profile photo!"); console.error(err); @@ -65,7 +96,12 @@ export default function ProfileSettings({

Profile Photo

- + + +

Profile Data

-
- {/*
- - setNewUniversity(e.target.value)} - /> -
*/} +
+ + setNewPronouns(e.target.value)} + /> +
+
- +