Skip to content

feat: add user profile image upload (#27)#38

Open
Chucks1093 wants to merge 1 commit intoPACTO-LAT:developfrom
Chucks1093:feat/user-profile-image-upload-27
Open

feat: add user profile image upload (#27)#38
Chucks1093 wants to merge 1 commit intoPACTO-LAT:developfrom
Chucks1093:feat/user-profile-image-upload-27

Conversation

@Chucks1093
Copy link

@Chucks1093 Chucks1093 commented Jan 31, 2026

Closes #27

Changes

  • Created user-avatars Supabase bucket with public read access
  • Updated ProfileInfo.tsx with image upload (follows merchant pattern)
  • File preview, upload to Supabase, and remove functionality
  • Path: avatars/{userId}-{timestamp}.{ext}

Testing

✅ Upload works in edit mode
✅ Preview displays
✅ Remove clears avatar
✅ URL saved to profile
✅ Works at /dashboard/profile

Summary by CodeRabbit

Release Notes

  • New Features
    • Avatar upload with instant preview and automatic profile update
    • Edit profile fields including name, username, email, phone, country, and bio
    • Toggle between view and edit modes for profile information
    • Enhanced KYC status badge display

✏️ Tip: You can customize this high-level summary in your review settings.

- Create user-avatars Supabase bucket
- Implement image upload in ProfileInfo component
- Add preview, upload, and remove functionality
- Follow merchant profile upload pattern
@vercel
Copy link

vercel bot commented Jan 31, 2026

@Chucks1093 is attempting to deploy a commit to the Trustless Work Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link

coderabbitai bot commented Jan 31, 2026

📝 Walkthrough

Walkthrough

ProfileInfo.tsx enhanced with functional avatar upload to Supabase Storage, including local preview state, file input, public URL retrieval, and user data updates. KYC status rendering refactored into a dedicated function. Existing profile form fields preserved with editing toggles intact.

Changes

Cohort / File(s) Summary
Avatar Upload Feature
apps/web/components/profile/ProfileInfo.tsx
Added complete avatar upload workflow with Supabase Storage integration, local preview state management, file input handling, public URL retrieval, and avatar_url persistence. Refactored KYC status rendering into dedicated function. Expanded imports for useState, Image, toast, supabase client, and X icon. Preserved existing form fields (name, username, email, phone, country, bio) with editing toggles and onUserDataChange binding semantics.

Sequence Diagram

sequenceDiagram
    actor User
    participant ProfileInfo as ProfileInfo Component
    participant Storage as Supabase Storage
    participant Database as User Data

    User->>ProfileInfo: Select avatar file
    activate ProfileInfo
    ProfileInfo->>ProfileInfo: Generate preview & storage path<br/>(user_id + timestamp)
    ProfileInfo->>User: Show local preview
    deactivate ProfileInfo

    User->>ProfileInfo: Confirm upload
    activate ProfileInfo
    ProfileInfo->>Storage: Upload file to bucket
    activate Storage
    Storage-->>ProfileInfo: Return public URL
    deactivate Storage

    ProfileInfo->>Database: Update avatar_url field
    activate Database
    Database-->>ProfileInfo: Update confirmed
    deactivate Database

    ProfileInfo->>ProfileInfo: Update avatarPreview state
    ProfileInfo->>User: Show success toast
    deactivate ProfileInfo
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 A portrait's uploaded to the sky,
Where Supabase keeps it safe and dry,
Preview before you commit the deed,
Then watch your avatar fill the need! 📸
No more bland profiles, hooray, hooray!

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add user profile image upload (#27)' accurately and concisely summarizes the primary change in the changeset.
Linked Issues check ✅ Passed The PR implements the core requirements from issue #27: image upload with Supabase, file preview, remove functionality, and proper URL persistence to user profile.
Out of Scope Changes check ✅ Passed All changes in ProfileInfo.tsx are directly related to implementing user profile image upload functionality as specified in issue #27.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@apps/web/components/profile/ProfileInfo.tsx`:
- Around line 39-63: Add a default branch to the getKycStatusBadge switch so
userData.kyc_status being undefined/null or any unexpected value doesn't return
undefined; update getKycStatusBadge to handle a fallback (e.g., return a neutral
Badge like "Unknown" or a hidden/empty badge) and ensure the returned element
uses the same Badge component for consistent layout and accessibility; reference
the getKycStatusBadge function and userData.kyc_status to locate and modify the
switch and add the default case.
- Around line 97-129: The file input handler currently allows any image/* and
doesn't validate size or MIME, and fileExt may be undefined; update the input's
accept prop to "image/jpeg,image/png,image/webp" and inside the onChange handler
validate file.type against the allowed MIME list and file.size ≤ 5 * 1024 *
1024, returning early with a toast.error if invalid; derive the extension from
file.type (or fall back to a safe default like "png") instead of using
file.name.split('.') which can return undefined, then proceed to call
supabase.storage.from("user-avatars").upload(path, file, { upsert: true }) and
update onUserDataChange and setAvatarPreview as before.
- Around line 156-159: The initials rendering in ProfileInfo
(userData.full_name) can produce undefined for empty parts; update the logic in
ProfileInfo.tsx to guard and produce a safe fallback: trim userData.full_name,
check it's non-empty, split(" "), filter out empty segments, map each segment
using charAt(0) (not [0]) and join; if the resulting initials string is empty,
use a sensible fallback such as userData.username or a placeholder like "?" to
avoid displaying "undefined". Ensure you change the expression that currently
uses userData.full_name.split(" ").map((n) => n[0]).join("") to the guarded
sequence and keep it localized to the ProfileInfo component.
🧹 Nitpick comments (4)
apps/web/components/profile/ProfileInfo.tsx (4)

3-3: Unused import: Camera icon.

The Camera icon is imported but never used in the component.

🧹 Remove unused import
-import { AlertCircle, Camera, CheckCircle, User, X } from "lucide-react";
+import { AlertCircle, CheckCircle, User, X } from "lucide-react";

35-37: Avatar preview state may become stale if userData prop changes.

The avatarPreview state is initialized from userData.avatar_url but won't update if the parent component re-fetches or resets the user data. Consider syncing with a useEffect or deriving the preview state from props when not actively uploading.

♻️ Sync avatarPreview when userData changes
+import { useState, useEffect } from "react";
...
 const [avatarPreview, setAvatarPreview] = useState<string>(
   userData.avatar_url || "",
 );
+
+useEffect(() => {
+  setAvatarPreview(userData.avatar_url || "");
+}, [userData.avatar_url]);

100-129: Extract upload handler to a named function.

Per coding guidelines, event handlers should use the handle prefix. The inline async handler is complex and would benefit from extraction for readability and testability. Also consider adding a loading state to provide user feedback during upload.

♻️ Extract to handleAvatarUpload function
+const [isUploading, setIsUploading] = useState(false);
+
+const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
+  const file = e.target.files?.[0];
+  if (!file) return;
+
+  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
+  if (!allowedTypes.includes(file.type)) {
+    toast.error('Please upload a JPEG, PNG, or WebP image');
+    return;
+  }
+
+  const maxSize = 5 * 1024 * 1024;
+  if (file.size > maxSize) {
+    toast.error('Image must be less than 5MB');
+    return;
+  }
+
+  setIsUploading(true);
+  try {
+    const timestamp = Date.now();
+    const fileExt = file.name.split(".").pop() || 'jpg';
+    const path = `avatars/${userData.id}-${timestamp}.${fileExt}`;
+
+    const { error } = await supabase.storage
+      .from("user-avatars")
+      .upload(path, file, { upsert: true });
+
+    if (error) throw error;
+
+    const { data } = supabase.storage
+      .from("user-avatars")
+      .getPublicUrl(path);
+
+    onUserDataChange({
+      ...userData,
+      avatar_url: data.publicUrl,
+    });
+    setAvatarPreview(data.publicUrl);
+    toast.success("Avatar uploaded");
+  } catch (err) {
+    console.error(err);
+    toast.error("Failed to upload avatar");
+  } finally {
+    setIsUploading(false);
+  }
+};

Then use it in the JSX:

<input
  type='file'
  accept='image/jpeg,image/png,image/webp'
  onChange={handleAvatarUpload}
  disabled={isUploading}
/>

As per coding guidelines: "Event handlers should start with 'handle' prefix (e.g., handleClick, handleSubmit)".


136-142: Clear avatar doesn't remove the file from Supabase storage.

The clear button resets the local state and avatar_url but doesn't delete the previously uploaded file from the user-avatars bucket. This could lead to orphaned files accumulating in storage over time.

Consider implementing storage cleanup either here or via a backend cleanup job. If implementing client-side deletion:

onClick={async () => {
  // Optionally delete from storage if URL exists
  if (userData.avatar_url) {
    const path = userData.avatar_url.split('/user-avatars/')[1];
    if (path) {
      await supabase.storage.from("user-avatars").remove([path]);
    }
  }
  onUserDataChange({ ...userData, avatar_url: "" });
  setAvatarPreview("");
}}

Comment on lines +39 to +63
const getKycStatusBadge = () => {
switch (userData.kyc_status) {
case "verified":
return (
<Badge className='bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'>
<CheckCircle className='w-3 h-3 mr-1' />
Verified
</Badge>
);
case "pending":
return (
<Badge className='bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'>
<AlertCircle className='w-3 h-3 mr-1' />
Pending
</Badge>
);
case "rejected":
return (
<Badge className='bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'>
<AlertCircle className='w-3 h-3 mr-1' />
Rejected
</Badge>
);
}
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing default case in getKycStatusBadge switch statement.

If userData.kyc_status is undefined, null, or an unexpected value, the function returns undefined, which will render nothing. Consider adding a default case for defensive handling.

🛡️ Add default case
       case "rejected":
         return (
           <Badge className='bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'>
             <AlertCircle className='w-3 h-3 mr-1' />
             Rejected
           </Badge>
         );
+      default:
+        return (
+          <Badge className='bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200'>
+            Unknown
+          </Badge>
+        );
     }
🤖 Prompt for AI Agents
In `@apps/web/components/profile/ProfileInfo.tsx` around lines 39 - 63, Add a
default branch to the getKycStatusBadge switch so userData.kyc_status being
undefined/null or any unexpected value doesn't return undefined; update
getKycStatusBadge to handle a fallback (e.g., return a neutral Badge like
"Unknown" or a hidden/empty badge) and ensure the returned element uses the same
Badge component for consistent layout and accessibility; reference the
getKycStatusBadge function and userData.kyc_status to locate and modify the
switch and add the default case.

Comment on lines +97 to +129
<input
type='file'
accept='image/*'
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
// Generate path with user ID and timestamp
const timestamp = Date.now();
const fileExt = file.name.split(".").pop();
const path = `avatars/${userData.id}-${timestamp}.${fileExt}`;

<div className="space-y-2">
<Label
htmlFor="bio"
className="text-sm font-medium text-muted-foreground"
>
Biography
</Label>
<Textarea
id="bio"
value={userData.bio}
onChange={(e) =>
onUserDataChange({ ...userData, bio: e.target.value })
}
disabled={!isEditing}
rows={3}
placeholder="Tell us about yourself and your trading experience..."
className="glass-effect-light"
/>
</div>
</CardContent>
</Card>
);
const { error } = await supabase.storage
.from("user-avatars")
.upload(path, file, { upsert: true });

if (error) throw error;

const { data } = supabase.storage
.from("user-avatars")
.getPublicUrl(path);

onUserDataChange({
...userData,
avatar_url: data.publicUrl,
});
setAvatarPreview(data.publicUrl);
toast.success("Avatar uploaded");
} catch (err) {
console.error(err);
toast.error("Failed to upload avatar");
}
}}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Missing file validation for type and size per issue requirements.

Issue #27 specifies restricting accepted file types to image/jpeg, image/png, image/webp and recommends a max size of ~5MB. The current implementation uses accept='image/*' which is too permissive, and there's no size validation before upload.

Additionally, the file extension extraction on line 106 could return undefined if the filename has no dot.

🛡️ Add file type and size validation
 <input
   type='file'
-  accept='image/*'
+  accept='image/jpeg,image/png,image/webp'
   onChange={async (e) => {
     const file = e.target.files?.[0];
     if (!file) return;
+
+    // Validate file type
+    const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
+    if (!allowedTypes.includes(file.type)) {
+      toast.error('Please upload a JPEG, PNG, or WebP image');
+      return;
+    }
+
+    // Validate file size (5MB max)
+    const maxSize = 5 * 1024 * 1024;
+    if (file.size > maxSize) {
+      toast.error('Image must be less than 5MB');
+      return;
+    }
+
     try {
       // Generate path with user ID and timestamp
       const timestamp = Date.now();
-      const fileExt = file.name.split(".").pop();
+      const fileExt = file.name.split(".").pop() || 'jpg';
       const path = `avatars/${userData.id}-${timestamp}.${fileExt}`;
📝 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
<input
type='file'
accept='image/*'
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
// Generate path with user ID and timestamp
const timestamp = Date.now();
const fileExt = file.name.split(".").pop();
const path = `avatars/${userData.id}-${timestamp}.${fileExt}`;
<div className="space-y-2">
<Label
htmlFor="bio"
className="text-sm font-medium text-muted-foreground"
>
Biography
</Label>
<Textarea
id="bio"
value={userData.bio}
onChange={(e) =>
onUserDataChange({ ...userData, bio: e.target.value })
}
disabled={!isEditing}
rows={3}
placeholder="Tell us about yourself and your trading experience..."
className="glass-effect-light"
/>
</div>
</CardContent>
</Card>
);
const { error } = await supabase.storage
.from("user-avatars")
.upload(path, file, { upsert: true });
if (error) throw error;
const { data } = supabase.storage
.from("user-avatars")
.getPublicUrl(path);
onUserDataChange({
...userData,
avatar_url: data.publicUrl,
});
setAvatarPreview(data.publicUrl);
toast.success("Avatar uploaded");
} catch (err) {
console.error(err);
toast.error("Failed to upload avatar");
}
}}
<input
type='file'
accept='image/jpeg,image/png,image/webp'
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
toast.error('Please upload a JPEG, PNG, or WebP image');
return;
}
// Validate file size (5MB max)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
toast.error('Image must be less than 5MB');
return;
}
try {
// Generate path with user ID and timestamp
const timestamp = Date.now();
const fileExt = file.name.split(".").pop() || 'jpg';
const path = `avatars/${userData.id}-${timestamp}.${fileExt}`;
const { error } = await supabase.storage
.from("user-avatars")
.upload(path, file, { upsert: true });
if (error) throw error;
const { data } = supabase.storage
.from("user-avatars")
.getPublicUrl(path);
onUserDataChange({
...userData,
avatar_url: data.publicUrl,
});
setAvatarPreview(data.publicUrl);
toast.success("Avatar uploaded");
} catch (err) {
console.error(err);
toast.error("Failed to upload avatar");
}
}}
🤖 Prompt for AI Agents
In `@apps/web/components/profile/ProfileInfo.tsx` around lines 97 - 129, The file
input handler currently allows any image/* and doesn't validate size or MIME,
and fileExt may be undefined; update the input's accept prop to
"image/jpeg,image/png,image/webp" and inside the onChange handler validate
file.type against the allowed MIME list and file.size ≤ 5 * 1024 * 1024,
returning early with a toast.error if invalid; derive the extension from
file.type (or fall back to a safe default like "png") instead of using
file.name.split('.') which can return undefined, then proceed to call
supabase.storage.from("user-avatars").upload(path, file, { upsert: true }) and
update onUserDataChange and setAvatarPreview as before.

Comment on lines +156 to +159
{userData.full_name
.split(" ")
.map((n) => n[0])
.join("")}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential runtime error if full_name is empty.

If userData.full_name is an empty string, calling .split(" ").map((n) => n[0]) will attempt to access n[0] on an empty string, returning undefined. This could result in displaying "undefined" in the fallback.

🛡️ Add defensive check
 <AvatarFallback className='text-lg'>
-  {userData.full_name
+  {(userData.full_name || "")
     .split(" ")
-    .map((n) => n[0])
-    .join("")}
+    .filter(Boolean)
+    .map((n) => n[0])
+    .join("") || <User className='w-8 h-8' />}
 </AvatarFallback>
🤖 Prompt for AI Agents
In `@apps/web/components/profile/ProfileInfo.tsx` around lines 156 - 159, The
initials rendering in ProfileInfo (userData.full_name) can produce undefined for
empty parts; update the logic in ProfileInfo.tsx to guard and produce a safe
fallback: trim userData.full_name, check it's non-empty, split(" "), filter out
empty segments, map each segment using charAt(0) (not [0]) and join; if the
resulting initials string is empty, use a sensible fallback such as
userData.username or a placeholder like "?" to avoid displaying "undefined".
Ensure you change the expression that currently uses userData.full_name.split("
").map((n) => n[0]).join("") to the guarded sequence and keep it localized to
the ProfileInfo component.

@Chucks1093
Copy link
Author

@aguilar1x pls review and merge

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create a Bucket for Storing User Profile Images

1 participant

Comments