From e0a7454c2457689e267dcc782bedd171cf524ac9 Mon Sep 17 00:00:00 2001 From: Elliot Saha Date: Thu, 28 Mar 2024 02:59:55 -0700 Subject: [PATCH] feat(profile/public): restructured public profile settings --- .../(protected)/profile/public/page.tsx | 166 ++++++-- .../(protected)/profile/setup/page.tsx | 382 ------------------ .../auth/email-verification/[token]/route.ts | 2 +- src/app/api/auth/profile/route.ts | 85 ++-- src/app/api/auth/profile/setup/route.ts | 85 ---- .../api/auth/signup/google/callback/route.ts | 2 +- 6 files changed, 189 insertions(+), 533 deletions(-) delete mode 100644 src/app/(pages)/(protected)/profile/setup/page.tsx delete mode 100644 src/app/api/auth/profile/setup/route.ts diff --git a/src/app/(pages)/(protected)/profile/public/page.tsx b/src/app/(pages)/(protected)/profile/public/page.tsx index 9a553bc..f538927 100644 --- a/src/app/(pages)/(protected)/profile/public/page.tsx +++ b/src/app/(pages)/(protected)/profile/public/page.tsx @@ -11,7 +11,6 @@ import { Heading, Icon, Image, - Select, Button, Spacer, FormControl, @@ -28,6 +27,7 @@ import { Text, Box, Skeleton, + IconButton, } from "@chakra-ui/react"; import { FiArrowRight, FiCamera, FiInstagram } from "react-icons/fi"; import z from "zod"; @@ -37,13 +37,33 @@ import axios from "axios"; import { useState, useCallback, useEffect } from "react"; import { useDropzone, FileRejection } from "react-dropzone"; import { getClientSession } from "@utils"; -import { UseFormSetValue, useForm } from "react-hook-form"; +import { Controller, UseFormSetValue, useForm } from "react-hook-form"; +import { AtSignIcon } from "@chakra-ui/icons"; +import { FormatOptionLabelMeta, Select } from "chakra-react-select"; +import { CloseIcon } from "@chakra-ui/icons"; +import { useSearchParams } from "next/navigation"; + +const skillOptions = [ + { value: 1, label: "I've never played before" }, + { value: 2, label: "I'm a beginner player" }, + { value: 3, label: "I'm an intermediate player" }, + { value: 4, label: "I'm an advanced player" }, +]; const schema = z.object({ first_name: z.string().min(1, ZOD_ERR.REQ_FIELD), last_name: z.string().min(1, ZOD_ERR.REQ_FIELD), - skill: z.string().min(1, ZOD_ERR.REQ_FIELD), - instagram: z.string().min(1, ZOD_ERR.REQ_FIELD), + skill: z.number(), + instagram: z + .literal("") + .or( + z + .string() + .regex( + /^([A-Za-z0-9_](?:(?:[A-Za-z0-9_]|(?:\.(?!\.))){0,28}(?:[A-Za-z0-9_]))?)$/, + "Invalid Instagram Username", + ), + ), profile: z.string().min(1), }); @@ -56,12 +76,16 @@ const initialFormUpdate = async (setValue: UseFormSetValue
) => { const AddInfo = () => { const statusToast = useToast(); + const searchParams = useSearchParams(); + const setup = searchParams.get("setup"); const { handleSubmit, register, setValue, + setFocus, watch, + control, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(schema) }); @@ -82,7 +106,7 @@ const AddInfo = () => { { first_name, last_name, - skill: parseInt(skill), + skill, instagram, profile, }, @@ -93,7 +117,6 @@ const AddInfo = () => { title: res.data.message, status: "success", }); - window.location.href = "/profile/public"; } } catch (e) { if (axios.isAxiosError(e)) { @@ -142,6 +165,8 @@ const AddInfo = () => { const { isOpen, onOpen, onClose } = useDisclosure(); + const [session, setSession] = useState({ user: { profile: "" } }); + const watched = watch(); useEffect(() => { @@ -152,7 +177,7 @@ const AddInfo = () => { const fetchSession = async () => { const session = await getUserFromSession(); - console.log(session.user); + setSession(session); setValue("first_name", session.user.first_name); setValue("last_name", session.user.last_name); setValue("skill", session.user.skill); @@ -163,15 +188,39 @@ const AddInfo = () => { fetchSession(); }, [setValue]); + interface SkillOptionSelect { + value: number; + label: string; + } + const SkillOption = ( + { value, label }: SkillOptionSelect, + meta: FormatOptionLabelMeta, + ) => { + const selected = + meta.context === "menu" && meta.selectValue?.[0]?.value === value; + return ( + + Skill {value} + + {label} + + + ); + }; + return ( - - Update your profile + + {setup ? "Complete your profile" : "Update your profile"} - + Lorem ipsum dolor sit amet, qui minim labore adipisicing minim sint cillum sint consectetur cupidatat. @@ -248,7 +297,7 @@ const AddInfo = () => { alt="profile" /> - Upload your new profile here + Upload new profile picture @@ -259,23 +308,34 @@ const AddInfo = () => { - - + { - - + + ( + + - - + + {watched.instagram !== "" ? ( + } + size="sm" + variant="ghost" + isDisabled={isSubmitting} + onClick={() => { + setValue("instagram", ""); + setFocus("instagram"); + }} + /> + ) : ( + + )} {errors?.instagram?.message} @@ -346,11 +430,11 @@ const AddInfo = () => { type="submit" isLoading={isSubmitting} loadingText="Updating ..." - size="md" + size={{ base: "lg", sm: "md" }} rightIcon={} - marginLeft={2} + w={{ base: "100%", sm: "auto" }} > - Update Profile + {setup ? "Complete" : "Update"} Profile diff --git a/src/app/(pages)/(protected)/profile/setup/page.tsx b/src/app/(pages)/(protected)/profile/setup/page.tsx deleted file mode 100644 index 763bb09..0000000 --- a/src/app/(pages)/(protected)/profile/setup/page.tsx +++ /dev/null @@ -1,382 +0,0 @@ -"use client"; -import { - InputLeftElement, - Input, - InputGroup, - InputRightElement, - Container, - Flex, - VStack, - Heading, - Icon, - Image, - Button, - Spacer, - FormControl, - FormErrorMessage, - useToast, - useDisclosure, - ModalBody, - Modal, - ModalContent, - ModalOverlay, - ModalHeader, - ModalCloseButton, - ModalFooter, - Text, - Box, - Skeleton, -} from "@chakra-ui/react"; -import { AtSignIcon } from "@chakra-ui/icons"; -import { FormatOptionLabelMeta, Select } from "chakra-react-select"; -import { FiArrowRight, FiCamera, FiInstagram } from "react-icons/fi"; -import z from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { DEFAULT_SERVER_ERR } from "@constants/error-messages"; -import axios from "axios"; -import { useState, useCallback, useEffect } from "react"; -import { useDropzone, FileRejection } from "react-dropzone"; -import { getClientSession } from "@utils"; -import { Controller, UseFormSetValue, useForm } from "react-hook-form"; -import { useRouter } from "next/navigation"; - -const schema = z.object({ - skill: z.object({ value: z.number(), label: z.string() }).optional(), - instagram: z.string().optional(), - profile: z.string().min(1), // should always have this -}); - -type Form = z.infer; - -const initialFormUpdate = async (setValue: UseFormSetValue) => { - const session = await getClientSession(); - console.log(session); - setValue("profile", session.user.profile); -}; - -const ProfileSetup = () => { - const statusToast = useToast(); - - const { - handleSubmit, - register, - setValue, - watch, - control, - formState: { errors, isSubmitting }, - } = useForm({ resolver: zodResolver(schema) }); - - useEffect(() => { - initialFormUpdate(setValue); - }, [setValue]); - - const router = useRouter(); - - const onSubmit = async ({ skill, instagram, profile }: Form) => { - try { - await axios.put( - `${process.env.NEXT_PUBLIC_HOSTNAME}/api/auth/profile/setup`, - { - skill: skill?.value || 1, - instagram, - profile, - }, - ); - - router.push("/"); - } catch (e) { - if (axios.isAxiosError(e)) { - statusToast({ - title: e?.response?.data?.message || DEFAULT_SERVER_ERR, - status: "error", - }); - } - } - }; - - const skip = () => { - window.location.href = "/"; - }; - - const MAX_IMG_SIZE: number = 1024 ** 2 * 2; - const [dropzoneError, setDropzoneError] = useState(false); - const onDrop = useCallback( - (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { - if (acceptedFiles.length !== 0) { - const file = acceptedFiles[0]; - const reader = new FileReader(); - reader.onabort = () => console.error("file reading was aborted"); - reader.onerror = () => console.error("file reading has failed"); - reader.onload = () => { - const binaryStr = reader.result as string; - setValue("profile", binaryStr); - setDropzoneError(false); - }; - reader.readAsDataURL(file); - } - if (rejectedFiles.length !== 0) { - const fileError = rejectedFiles[0]; - setDropzoneError(fileError.errors[0].code); - } - }, - [setValue], - ); - - const { getRootProps, getInputProps } = useDropzone({ - onDrop, - accept: { - "image/jpeg": [], - "image/png": [], - "image/jpg": [], - "image/svg+xml": [], - }, - maxSize: MAX_IMG_SIZE, - }); - - const { isOpen, onOpen, onClose } = useDisclosure(); - - const watched = watch(); - - useEffect(() => { - const getUserFromSession = async () => { - const session = await getClientSession(); - return session; - }; - - const fetchSession = async () => { - const session = await getUserFromSession(); - setValue("profile", session.user.profile); - }; - - fetchSession(); - }, [setValue]); - - interface SkillOptionSelect { - value: number; - label: string; - } - const SkillOption = ( - { value, label }: SkillOptionSelect, - meta: FormatOptionLabelMeta, - ) => { - console.log(meta); - const selected = - meta.context === "menu" && meta.selectValue?.[0]?.value === value; - return ( - - Skill {value} - - {label} - - - ); - }; - - console.log(watched); - return ( - - - - - - Complete your profile - - - Lorem ipsum dolor sit amet, qui minim labore adipisicing minim - sint cillum sint consectetur cupidatat. - - {watched.profile ? ( - - Profile - - - - - ) : ( - - )} - - - - - - - - - {dropzoneError === "file-invalid-type" && ( - - Image must be of either SVG, JPG, JPEG, or PNG format. - - )} - {dropzoneError === "file-too-large" && ( - - Image must be less than 2 MB in size. - - )} - - - - - Profile - - Upload your new profile here - - - Click here to upload new picture - - - - - {errors?.profile?.message} - - - - - - - - - - - - - ( - - - - - - - - - {errors?.instagram?.message} - - - - - - - - - - - - - - ); -}; - -export default ProfileSetup; diff --git a/src/app/api/auth/email-verification/[token]/route.ts b/src/app/api/auth/email-verification/[token]/route.ts index f8b978e..ff18726 100644 --- a/src/app/api/auth/email-verification/[token]/route.ts +++ b/src/app/api/auth/email-verification/[token]/route.ts @@ -57,7 +57,7 @@ export const GET = async ( } if (success) { - redirect("/profile/setup"); + redirect("/profile/public?setup=true"); } else { redirect("/login?confirmation-status=failed"); } diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts index e67cd90..6d7c579 100644 --- a/src/app/api/auth/profile/route.ts +++ b/src/app/api/auth/profile/route.ts @@ -1,34 +1,43 @@ -import {getSession} from '@helpers/getSession'; -import {ServerResponse} from '@helpers/serverResponse'; -import {connectToDatabase} from '@lib/mongoose'; -import {User} from '@models/User'; -import {NextRequest} from 'next/server'; -import z from 'zod'; +import { connectToDatabase } from "@lib/mongoose"; +import { User } from "@models/User"; +import { NextRequest } from "next/server"; +import z from "zod"; +import { dataURLtoFile, ServerResponse, getSession } from "@helpers"; +import { UTApi } from "uploadthing/server"; const updateProfileSchema = z.object({ - first_name: z.string({required_error: 'First name is required'}), - last_name: z.string({required_error: 'Last name is required'}), - skill: z.number({required_error: 'Skill level is required'}).refine( - (skillValue: number) => { - const LESS_THAN_MAX = skillValue <= 5; - const MORE_THAN_MIN = skillValue >= 1; - return LESS_THAN_MAX && MORE_THAN_MIN; - }, - {message: 'Skill level must be between 1 and 5'} - ), - instagram: z.string({required_error: 'Instagram is required'}), - profile: z.string({required_error: 'Profile is required'}), + first_name: z.string({ required_error: "First name is required" }), + last_name: z.string({ required_error: "Last name is required" }), + skill: z.number().int().max(4).min(1).optional(), + instagram: z + .literal("") + .or( + z + .string() + .regex( + /^([A-Za-z0-9_](?:(?:[A-Za-z0-9_]|(?:\.(?!\.))){0,28}(?:[A-Za-z0-9_]))?)$/, + "Invalid Instagram Username", + ), + ), + profile: z.string({ required_error: "Profile is required" }), }); +const base64ImageType = (dataStr: string) => { + return dataStr.match(/^data:(.+);base64/)?.[1]; +}; +const isBase64Image = (input: string): boolean => { + return base64ImageType(input)?.split("/")[0] === "image"; +}; + export const PUT = async (request: NextRequest) => { await connectToDatabase(); const body = await request.json(); - const {first_name, last_name, skill, instagram, profile} = + const { first_name, last_name, skill, instagram, profile } = structuredClone(body); - const {session} = await getSession(request); + const { session } = await getSession(request); if (!session) { return ServerResponse.unauthorizedError(); @@ -46,15 +55,45 @@ export const PUT = async (request: NextRequest) => { if (validation.success) { try { + let pfpUrl = profile; + + const imageType = base64ImageType(profile); + + if (profile && isBase64Image(profile) && imageType) { + // uploading image to uploadthing if base64 + const utapi = new UTApi({ + apiKey: process.env.NEXT_UPLOADTHING_SECRET, + }); + + const file = await dataURLtoFile( + profile, + session.user.userId, + imageType, + ); + + // bytes -> kb -> mb is greater than 2mb + if (file.size > 1024 ** 2 * 2) { + return ServerResponse.serverError("User image is too big"); + } + + const uploadRes = await utapi.uploadFiles([file]); + + if (uploadRes[0].error) { + return ServerResponse.serverError("Could not process User image"); + } + + pfpUrl = uploadRes[0].data.url; + } + await User.findByIdAndUpdate(userId, { first_name, last_name, - skill, + skill: skill || 1, instagram, - profile, + profile: pfpUrl, }); - return ServerResponse.success('Successfully updated user attributes'); + return ServerResponse.success("Successfully updated user attributes"); } catch (e) { return ServerResponse.serverError(); } diff --git a/src/app/api/auth/profile/setup/route.ts b/src/app/api/auth/profile/setup/route.ts deleted file mode 100644 index c764596..0000000 --- a/src/app/api/auth/profile/setup/route.ts +++ /dev/null @@ -1,85 +0,0 @@ -import {getSession} from '@helpers/getSession'; -import {ServerResponse} from '@helpers/serverResponse'; -import {connectToDatabase} from '@lib/mongoose'; -import {logger} from '@lib/winston'; -import {User} from '@models/User'; -import {NextRequest} from 'next/server'; -import z from 'zod'; -import {UTApi} from 'uploadthing/server'; -import {dataURLtoFile} from '@helpers/dataURLtoFile'; - -const setupSchema = z.object({ - skill: z.number().int().max(4).min(1).optional(), - instagram: z.string().optional(), - profile: z.string({required_error: 'Profile is required'}), -}); - -const base64ImageType = (dataStr: string) => { - return dataStr.match(/^data:(.+);base64/)?.[1]; -}; -const isBase64Image = (input: string): boolean => { - return base64ImageType(input)?.split('/')[0] === 'image'; -}; - -export const PUT = async (request: NextRequest) => { - await connectToDatabase(); - - const body: z.infer = await request.json(); - - const validation = setupSchema.safeParse(body); - - const {session} = await getSession(request); - - if (!session) { - return ServerResponse.unauthorizedError(); - } - - if (validation.success) { - try { - const {skill, instagram, profile} = body; - - let pfpUrl = profile; - - const imageType = base64ImageType(profile); - - if (profile && isBase64Image(profile) && imageType) { - // uploading image to uploadthing if base64 - const utapi = new UTApi({ - apiKey: process.env.NEXT_UPLOADTHING_SECRET, - }); - - const file = await dataURLtoFile( - profile, - session.user.userId, - imageType - ); - - // bytes -> kb -> mb is greater than 2mb - if (file.size > 1024 ** 2 * 2) { - return ServerResponse.serverError('User image is too big'); - } - - const uploadRes = await utapi.uploadFiles([file]); - - if (uploadRes[0].error) { - return ServerResponse.serverError('Could not process User image'); - } - - pfpUrl = uploadRes[0].data.url; - } - - await User.findByIdAndUpdate(session.user.userId, { - skill: skill || 1, - instagram: instagram, - profile: pfpUrl, - }); - - return ServerResponse.success('Successfully updated user attributes'); - } catch (e) { - logger.error(e); - return ServerResponse.serverError(); - } - } else { - return ServerResponse.validationError(validation); - } -}; diff --git a/src/app/api/auth/signup/google/callback/route.ts b/src/app/api/auth/signup/google/callback/route.ts index 4eea0b7..ab947f4 100644 --- a/src/app/api/auth/signup/google/callback/route.ts +++ b/src/app/api/auth/signup/google/callback/route.ts @@ -56,7 +56,7 @@ export const GET = async (request: NextRequest) => { }); authRequest.setSession(session); return NextResponse.redirect( - `${process.env.NEXT_PUBLIC_HOSTNAME}/profile/setup`, + `${process.env.NEXT_PUBLIC_HOSTNAME}/profile/profile?setup=true`, ); } catch (e) { if (e instanceof OAuthRequestError) {