feat: add user profile image upload (#27)#38
feat: add user profile image upload (#27)#38Chucks1093 wants to merge 1 commit intoPACTO-LAT:developfrom
Conversation
- Create user-avatars Supabase bucket - Implement image upload in ProfileInfo component - Add preview, upload, and remove functionality - Follow merchant profile upload pattern
|
@Chucks1093 is attempting to deploy a commit to the Trustless Work Team on Vercel. A member of the Team first needs to authorize it. |
📝 WalkthroughWalkthroughProfileInfo.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
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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:Cameraicon.The
Cameraicon 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 ifuserDataprop changes.The
avatarPreviewstate is initialized fromuserData.avatar_urlbut won't update if the parent component re-fetches or resets the user data. Consider syncing with auseEffector 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
handleprefix. 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_urlbut doesn't delete the previously uploaded file from theuser-avatarsbucket. 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(""); }}
| 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> | ||
| ); | ||
| } | ||
| }; |
There was a problem hiding this comment.
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.
| <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"); | ||
| } | ||
| }} |
There was a problem hiding this comment.
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.
| <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.
| {userData.full_name | ||
| .split(" ") | ||
| .map((n) => n[0]) | ||
| .join("")} |
There was a problem hiding this comment.
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.
|
@aguilar1x pls review and merge |
Closes #27
Changes
user-avatarsSupabase bucket with public read accessProfileInfo.tsxwith image upload (follows merchant pattern)avatars/{userId}-{timestamp}.{ext}Testing
✅ Upload works in edit mode
✅ Preview displays
✅ Remove clears avatar
✅ URL saved to profile
✅ Works at
/dashboard/profileSummary by CodeRabbit
Release Notes
✏️ Tip: You can customize this high-level summary in your review settings.