Skip to content
83 changes: 80 additions & 3 deletions app/(app)/alpha/additional-details/_client.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { redirect, useRouter, useSearchParams } from "next/navigation";
import { useSession } from "next-auth/react";
import {
Expand Down Expand Up @@ -39,6 +39,10 @@ import { Select } from "@/components/ui-components/select";
import { Button } from "@/components/ui-components/button";
import { Heading, Subheading } from "@/components/ui-components/heading";
import { Divider } from "@/components/ui-components/divider";
import { Avatar } from "@/components/ui-components/avatar";
import { Text } from "@/components/ui-components/text";
import { api } from "@/server/trpc/react";
import { imageUploadToUrl } from "@/utils/fileUpload";

type UserDetails = {
username: string;
Expand All @@ -51,6 +55,12 @@ type UserDetails = {
levelOfStudy: string;
jobTitle: string;
workplace: string;
image: string;
};

type ProfilePhoto = {
status: "success" | "error" | "loading" | "idle";
url: string;
};

export default function AdditionalSignUpDetails({
Expand Down Expand Up @@ -99,9 +109,15 @@ export default function AdditionalSignUpDetails({

function SlideOne({ details }: { details: UserDetails }) {
const router = useRouter();

const fileInputRef = useRef<HTMLInputElement>(null);
const [profilePhoto, setProfilePhoto] = useState<ProfilePhoto>({
status: "idle",
url: details.image,
});
const { username, name, location } = details;

const { mutateAsync: getUploadUrl } = api.profile.getUploadUrl.useMutation();
const { mutateAsync: updateUserPhotoUrl } =
api.profile.updateProfilePhotoUrl.useMutation();
const {
register,
handleSubmit,
Expand All @@ -111,6 +127,26 @@ function SlideOne({ details }: { details: UserDetails }) {
defaultValues: { username, name, location },
});

const handleImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
try {
setProfilePhoto({ status: "loading", url: "" });
const file = e.target.files[0];
const { status, fileLocation } = await imageUploadToUrl({
file,
updateUserPhotoUrl,
getUploadUrl,
});
setProfilePhoto({ status: status, url: fileLocation });
} catch (error) {
toast.error("Failed to upload profile photo. Please try again.");
setProfilePhoto({ status: "error", url: "" });
}
} else {
toast.error("Failed to upload profile photo. Please try again.");
}
};

const onFormSubmit = async (data: TypeSlideOneSchema) => {
try {
const isSuccess = await slideOneSubmitAction(data);
Expand All @@ -135,6 +171,47 @@ function SlideOne({ details }: { details: UserDetails }) {
</Subheading>
</div>
<Divider className="my-4 mt-4" />

<div className="mx-4 my-4">
<Field className="flex-grow">
<Label>Profile Picture</Label>
<div className="mt-3 flex items-center justify-between gap-4 px-3">
<Avatar
square
src={
profilePhoto.status === "error" ||
profilePhoto.status === "loading"
? undefined
: `${profilePhoto.url}`
}
Comment on lines +179 to +186
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add loading indicator for avatar upload

To improve user experience, consider adding a loading indicator when the avatar is being uploaded. This can be done by utilizing the profilePhoto.status state:

 <Avatar
   square
   src={
     profilePhoto.status === "error" ||
     profilePhoto.status === "loading"
       ? undefined
       : `${profilePhoto.url}`
   }
   alt="Profile photo upload section"
-  className="h-16 w-16 overflow-hidden rounded-full"
+  className={`h-16 w-16 overflow-hidden rounded-full ${
+    profilePhoto.status === "loading" ? "animate-pulse bg-gray-200" : ""
+  }`}
 />
+{profilePhoto.status === "loading" && (
+  <div className="absolute inset-0 flex items-center justify-center">
+    <span className="loading loading-spinner loading-md"></span>
+  </div>
+)}

This will show a pulsing animation and a spinner when the avatar is being uploaded, providing visual feedback to the user.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Avatar
square
src={
profilePhoto.status === "error" ||
profilePhoto.status === "loading"
? undefined
: `${profilePhoto.url}`
}
<Avatar
square
src={
profilePhoto.status === "error" ||
profilePhoto.status === "loading"
? undefined
: `${profilePhoto.url}`
}
alt="Profile photo upload section"
className={`h-16 w-16 overflow-hidden rounded-full ${
profilePhoto.status === "loading" ? "animate-pulse bg-gray-200" : ""
}`}
/>
{profilePhoto.status === "loading" && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="loading loading-spinner loading-md"></span>
</div>
)}

alt="Profile photo upload section"
className="h-16 w-16 overflow-hidden rounded-full"
/>
<div className="pt-[30px]">
<Button
color="dark/white"
type="button"
className="h-[30px] rounded-md text-xs"
onClick={() => fileInputRef.current?.click()}
>
Change avatar
</Button>
<Input
type="file"
id="file-input"
name="user-photo"
accept="image/png, image/gif, image/jpeg"
onChange={handleImageChange}
className="hidden"
ref={fileInputRef}
/>
<Text className="mt-1 text-xs text-gray-500">
JPG, GIF or PNG. 10MB max.
</Text>
</div>
</div>
</Field>
</div>
<div className="mx-4">
<Field>
<Label>Full Name</Label>
Expand Down
2 changes: 2 additions & 0 deletions app/(app)/alpha/additional-details/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export default async function Page() {
levelOfStudy: true,
jobTitle: true,
workplace: true,
image: true,
},
where: (user, { eq }) => eq(user.id, userId),
});
Expand All @@ -37,6 +38,7 @@ export default async function Page() {
levelOfStudy: details?.levelOfStudy || "",
jobTitle: details?.jobTitle || "",
workplace: details?.workplace || "",
image: details?.image || "",
};

return <Content details={detailsWithNullsRemoved} />;
Expand Down
71 changes: 21 additions & 50 deletions app/(app)/settings/_client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { toast } from "sonner";
import type { saveSettingsInput } from "@/schema/profile";
import { saveSettingsSchema } from "@/schema/profile";

import { uploadFile } from "@/utils/s3helpers";
import type { user } from "@/server/db/schema";
import { Button } from "@/components/ui-components/button";
import { Loader2 } from "lucide-react";
Expand All @@ -25,6 +24,7 @@ import { Textarea } from "@/components/ui-components/textarea";
import { Switch } from "@/components/ui-components/switch";
import { Divider } from "@/components/ui-components/divider";
import { Text } from "@/components/ui-components/text";
import { imageUploadToUrl } from "@/utils/fileUpload";

type User = Pick<
typeof user.$inferSelect,
Expand Down Expand Up @@ -75,8 +75,8 @@ const Settings = ({ profile }: { profile: User }) => {
});

const { mutate, isError, isSuccess } = api.profile.edit.useMutation();
const { mutate: getUploadUrl } = api.profile.getUploadUrl.useMutation();
const { mutate: updateUserPhotoUrl } =
const { mutateAsync: getUploadUrl } = api.profile.getUploadUrl.useMutation();
const { mutateAsync: updateUserPhotoUrl } =
api.profile.updateProfilePhotoUrl.useMutation();
const { mutate: updateEmail } = api.profile.updateEmail.useMutation();
Comment on lines +79 to 81
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codebase verification

Action Required: Update updateEmail to use mutateAsync

The updateEmail mutation hook is still using mutate. For consistent asynchronous handling, it should be updated to use mutateAsync similar to updateUserPhotoUrl.

  • File: app/(app)/settings/_client.tsx
    • Line: 81
🔗 Analysis chain

LGTM: Mutation hook updated to use mutateAsync

The updateUserPhotoUrl mutation hook has been correctly updated to use mutateAsync. This change allows for better handling of asynchronous operations.

Let's verify if similar changes have been applied to other mutation hooks in the file:


Let's attempt to verify the usage of mutateAsync in other mutation hooks by running an updated shell script:


Let's attempt to verify the usage of mutateAsync in other mutation hooks by running an updated shell script:


Let's attempt to verify the usage of mutateAsync in other mutation hooks by running an updated shell script:


Let's attempt to verify if there are other instances of mutateAsync in the mutation hooks by running an updated shell script:

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Check if other mutation hooks have been updated to use mutateAsync
rg --type typescript 'const \{ mutate(Async)?: \w+ \} = api\.\w+\.\w+\.useMutation\(\);' app/\(app\)/settings/_client.tsx

Length of output: 158


Script:

#!/bin/bash
# Check if other mutation hooks have been updated to use mutateAsync
rg --type tsx 'const \{ mutateAsync: \w+ \} = api\.\w+\.\w+\.useMutation\(\);' app/(app)/settings/_client.tsx

Length of output: 246


Script:

#!/bin/bash
# Check if other mutation hooks have been updated to use mutateAsync
rg --type tsx 'const \{ mutateAsync: \w+ \} = api\.\w+\.\w+\.useMutation\(\);' 'app/\(app\)/settings/_client.tsx'

Length of output: 143


Script:

#!/bin/bash
# Check if other mutation hooks have been updated to use mutateAsync
rg -g "*.tsx" 'const \{ mutateAsync: \w+ \} = api\.\w+\.\w+\.useMutation\(\);' 'app/(app)/settings/_client.tsx'

Length of output: 113


Script:

#!/bin/bash
# Search for any usage of mutateAsync in mutation hooks within the specified file
rg -F 'mutateAsync' 'app/(app)/settings/_client.tsx'

Length of output: 98

Comment on lines +78 to 81
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Update remaining mutation hook to use mutateAsync

Great job updating the getUploadUrl and updateUserPhotoUrl hooks to use mutateAsync. This change improves error handling and consistency in asynchronous operations.

However, the updateEmail hook on line 81 still uses mutate. For consistency, please update it as well:

-const { mutate: updateEmail } = api.profile.updateEmail.useMutation();
+const { mutateAsync: updateEmail } = api.profile.updateEmail.useMutation();

Don't forget to refactor the handleNewEmailUpdate function to use try/catch with async/await when calling this mutation.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { mutateAsync: getUploadUrl } = api.profile.getUploadUrl.useMutation();
const { mutateAsync: updateUserPhotoUrl } =
api.profile.updateProfilePhotoUrl.useMutation();
const { mutate: updateEmail } = api.profile.updateEmail.useMutation();
const { mutateAsync: getUploadUrl } = api.profile.getUploadUrl.useMutation();
const { mutateAsync: updateUserPhotoUrl } =
api.profile.updateProfilePhotoUrl.useMutation();
const { mutateAsync: updateEmail } = api.profile.updateEmail.useMutation();


Expand Down Expand Up @@ -104,52 +104,23 @@ const Settings = ({ profile }: { profile: User }) => {
mutate({ ...values, newsletter: weeklyNewsletter, emailNotifications });
};

const uploadToUrl = async (signedUrl: string, file: File) => {
setProfilePhoto({ status: "loading", url: "" });

if (!file) {
setProfilePhoto({ status: "error", url: "" });
toast.error("Invalid file upload.");
return;
}

const response = await uploadFile(signedUrl, file);
const { fileLocation } = response;
await updateUserPhotoUrl({
url: fileLocation,
});

return fileLocation;
};

const imageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const handleImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
const { size, type } = file;

await getUploadUrl(
{ size, type },
{
onError(error) {
if (error) return toast.error(error.message);
return toast.error(
"Something went wrong uploading the photo, please retry.",
);
},
async onSuccess(signedUrl) {
const url = await uploadToUrl(signedUrl, file);
if (!url) {
return toast.error(
"Something went wrong uploading the photo, please retry.",
);
}
setProfilePhoto({ status: "success", url });
toast.success(
"Profile photo successfully updated. This may take a few minutes to update around the site.",
);
},
},
);
try {
setProfilePhoto({ status: "loading", url: "" });
const file = e.target.files[0];
const { status, fileLocation } = await imageUploadToUrl({
file,
updateUserPhotoUrl,
getUploadUrl,
});
setProfilePhoto({ status: status, url: fileLocation });
} catch (error) {
toast.error("Failed to upload profile photo. Please try again.");
setProfilePhoto({ status: "error", url: "" });
}
} else {
toast.error("Failed to upload profile photo. Please try again.");
}
};

Expand Down Expand Up @@ -225,12 +196,12 @@ const Settings = ({ profile }: { profile: User }) => {
id="file-input"
name="user-photo"
accept="image/png, image/gif, image/jpeg"
onChange={imageChange}
onChange={handleImageChange}
className="hidden"
ref={fileInputRef}
/>
<Text className="mt-1 text-xs text-gray-500">
JPG, GIF or PNG. 1MB max.
JPG, GIF or PNG. 10MB max.
</Text>
</div>
</div>
Expand Down
56 changes: 56 additions & 0 deletions utils/fileUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { toast } from "sonner";
import { uploadFile } from "./s3helpers";

type UploadToUrlProps = {
file: File;
getUploadUrl: (data: { size: number; type: string }) => Promise<string>;
updateUserPhotoUrl: (data: {
url: string;
}) => Promise<{ role: string; id: string; name: string }>;
};

type UploadResult = {
status: "success" | "error" | "loading";
fileLocation: string;
};

export const imageUploadToUrl = async ({
file,
updateUserPhotoUrl,
getUploadUrl,
}: UploadToUrlProps): Promise<UploadResult> => {
if (!file) {
toast.error("Invalid file upload.");
return { status: "error", fileLocation: "" };
}
Comment on lines +17 to +25
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Enhance initial file validation

While the initial file check is a good start, consider implementing more comprehensive validation as suggested in the previous review:

  1. Make the error message more specific: "No file selected for upload" instead of "Invalid file upload."
  2. Add file type and size validation before proceeding with the upload.

Example implementation:

if (!file) {
  toast.error("No file selected for upload.");
  return { status: "error", fileLocation: "" };
}

const allowedTypes = ["image/jpeg", "image/png"];
const maxSize = 5 * 1024 * 1024; // 5 MB

if (!allowedTypes.includes(file.type)) {
  toast.error("Invalid file type. Please upload a JPEG or PNG image.");
  return { status: "error", fileLocation: "" };
}

if (file.size > maxSize) {
  toast.error("File size exceeds the 5MB limit. Please upload a smaller file.");
  return { status: "error", fileLocation: "" };
}

This will improve error handling and prevent potential issues with unsupported file types or oversized files.


try {
const { size, type } = file;
const signedUrl = await getUploadUrl({ size, type });

if (!signedUrl) {
toast.error("Failed to upload profile photo. Please try again.");
return { status: "error", fileLocation: "" };
}

const response = await uploadFile(signedUrl, file);

const { fileLocation } = response;

if (!fileLocation) {
toast.error("Failed to retrieve file location after upload.");
return { status: "error", fileLocation: "" };
}
await updateUserPhotoUrl({ url: fileLocation });
toast.success("Profile photo successfully updated.");

return { status: "success", fileLocation };
} catch (error) {
if (error instanceof Error) {
toast.error(error.message);
} else {
toast.error("Failed to upload profile photo. Please try again.");
}
return { status: "error", fileLocation: "" };
}
};
Comment on lines +48 to +56
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Enhance error logging for debugging

The error handling in the catch block is good, differentiating between Error instances and unknown errors. To further improve debugging capabilities, consider adding error logging:

} catch (error) {
  console.error('Image upload failed:', error);
  if (error instanceof Error) {
    toast.error(error.message);
  } else {
    toast.error("Failed to upload profile photo. Please try again.");
  }
  return { status: "error", fileLocation: "" };
}

This will help with identifying and resolving issues in production environments while maintaining the user-friendly error messages.