From c845de7e5e5b992b06f0dc291e2909fbe4e72ce9 Mon Sep 17 00:00:00 2001 From: Peolite001 Date: Thu, 18 Jun 2026 22:34:17 +0100 Subject: [PATCH] feat(avatar): implement avatar upload in settings - Add useAvatarUpload hook with client-side validation - Add AvatarUpload component with preview, progress, and error states - Update useUserStore with avatar set/remove actions - Add upload method to useApi hook - Replace 'coming soon' placeholder in settings page Closes #13 --- src/app/[locale]/settings/page.tsx | 54 ++++++--- src/app/components/AvatarUpload.tsx | 161 +++++++++++++++++++++++++ src/app/hooks/useApi.ts | 36 ++++++ src/app/hooks/useAvatarUpload.ts | 175 ++++++++++++++++++++++++++++ src/app/stores/useUserStore.ts | 16 +++ 5 files changed, 428 insertions(+), 14 deletions(-) create mode 100644 src/app/components/AvatarUpload.tsx create mode 100644 src/app/hooks/useAvatarUpload.ts diff --git a/src/app/[locale]/settings/page.tsx b/src/app/[locale]/settings/page.tsx index f44917e..b987ba5 100644 --- a/src/app/[locale]/settings/page.tsx +++ b/src/app/[locale]/settings/page.tsx @@ -26,6 +26,8 @@ import { } from "../../stores/useWalletStore"; import { useUserStore, selectUser } from "../../stores/useUserStore"; import { logoutUser } from "../../lib/session"; +import { useTranslations } from "next-intl"; +import { AvatarUpload } from "../../components/AvatarUpload"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -121,7 +123,9 @@ function Toggle({ function ProfileSection() { const user = useUserStore(selectUser); - const [displayName, setDisplayName] = useState(user?.id ?? ""); + const setAvatar = useUserStore((s) => s.setAvatar); + const removeAvatar = useUserStore((s) => s.removeAvatar); + const [displayName, setDisplayName] = useState(user?.name ?? ""); const [email, setEmail] = useState(user?.email ?? ""); const [saved, setSaved] = useState(false); @@ -131,6 +135,28 @@ function ProfileSection() { setTimeout(() => setSaved(false), 2000); }; + const handleAvatarChange = (url: string) => { + setAvatar(url); + }; + + const handleAvatarRemove = async () => { + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/users/avatar`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${localStorage.getItem("authToken") || ""}`, + }, + } + ); + if (!res.ok) throw new Error("Failed to remove avatar"); + removeAvatar(); + } catch (err) { + console.error("Failed to remove avatar:", err); + } + }; + return ( @@ -139,18 +165,18 @@ function ProfileSection() { Manage your public display name and contact info.

- - {/* Avatar */} -
-
- -
-
-

Profile Picture

-

- Avatars are not supported yet — coming soon. -

-
+ + {/* Avatar Upload */} +
+

+ Profile Picture +

+
); -} +} \ No newline at end of file diff --git a/src/app/components/AvatarUpload.tsx b/src/app/components/AvatarUpload.tsx new file mode 100644 index 0000000..56246cd --- /dev/null +++ b/src/app/components/AvatarUpload.tsx @@ -0,0 +1,161 @@ +'use client'; + +import Image from 'next/image'; +import { useAvatarUpload } from '@/app/hooks/useAvatarUpload'; +import { cn } from '@/lib/utils'; // or your class merging utility + +interface AvatarUploadProps { + currentAvatarUrl?: string | null; + userName?: string; + onAvatarChange?: (url: string) => void; + onAvatarRemove?: () => void; + size?: 'sm' | 'md' | 'lg'; +} + +const sizeClasses = { + sm: 'w-16 h-16 text-sm', + md: 'w-24 h-24 text-base', + lg: 'w-32 h-32 text-lg', +}; + +export function AvatarUpload({ + currentAvatarUrl, + userName = 'User', + onAvatarChange, + onAvatarRemove, + size = 'lg', +}: AvatarUploadProps) { + const { + uploadState, + progress, + preview, + error, + fileInputRef, + handleInputChange, + triggerFileSelect, + clearError, + } = useAvatarUpload({ + onSuccess: onAvatarChange, + }); + + const isUploading = uploadState === 'uploading'; + const hasError = uploadState === 'error'; + const displayUrl = preview || currentAvatarUrl; + const initials = userName + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2); + + return ( +
+ {/* Avatar Display */} +
+
+ {displayUrl ? ( + {`${userName}'s + ) : ( + {initials} + )} + + {/* Upload Progress Overlay */} + {isUploading && ( +
+ {progress}% +
+
+
+
+ )} + + {/* Hover Overlay */} + {!isUploading && ( + + )} +
+ + {/* Success Indicator */} + {uploadState === 'success' && ( +
+ ✓ +
+ )} +
+ + {/* Hidden File Input */} + + + {/* Action Buttons */} +
+ + + {currentAvatarUrl && !isUploading && ( + + )} +
+ + {/* Error Message */} + {error && ( +
+ + {error} + +
+ )} + + {/* Helper Text */} +

+ JPG, PNG, or WebP. Max 5MB. Max 2048x2048px. +

+
+ ); +} \ No newline at end of file diff --git a/src/app/hooks/useApi.ts b/src/app/hooks/useApi.ts index 971e72d..69c1968 100644 --- a/src/app/hooks/useApi.ts +++ b/src/app/hooks/useApi.ts @@ -1218,3 +1218,39 @@ export async function submitLoanTransaction(signedTxXdr: string) { body: JSON.stringify({ signedTxXdr }), }); } + + +// ─── Api hook ────────────────────────────────────────────────────── +export function useApi() { + + const upload = async ( + endpoint: string, + file: File, + options?: { headers?: Record } + ) => { + const formData = new FormData(); + formData.append('avatar', file); + + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}${endpoint}`, { + method: 'POST', + body: formData, + headers: { + // Don't set Content-Type — browser sets it with boundary for FormData + Authorization: `Bearer ${getToken()}`, // your auth method + ...options?.headers, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Upload failed' })); + throw new Error(error.message || `Upload failed: ${response.status}`); + } + + return response.json(); + }; + + return { + // ... existing exports + upload, + }; +} diff --git a/src/app/hooks/useAvatarUpload.ts b/src/app/hooks/useAvatarUpload.ts new file mode 100644 index 0000000..fcd2b0f --- /dev/null +++ b/src/app/hooks/useAvatarUpload.ts @@ -0,0 +1,175 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; +import { useApi } from './useApi'; + +export type UploadState = 'idle' | 'validating' | 'uploading' | 'success' | 'error'; + +interface ValidationResult { + valid: boolean; + error?: string; +} + +interface UseAvatarUploadOptions { + maxSizeMB?: number; + acceptedTypes?: string[]; + maxDimension?: number; + onSuccess?: (url: string) => void; + onError?: (error: string) => void; +} + +const DEFAULT_MAX_SIZE_MB = 5; +const DEFAULT_ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp']; +const DEFAULT_MAX_DIMENSION = 2048; + +export function useAvatarUpload({ + maxSizeMB = DEFAULT_MAX_SIZE_MB, + acceptedTypes = DEFAULT_ACCEPTED_TYPES, + maxDimension = DEFAULT_MAX_DIMENSION, + onSuccess, + onError, +}: UseAvatarUploadOptions = {}) { + const [uploadState, setUploadState] = useState('idle'); + const [progress, setProgress] = useState(0); + const [preview, setPreview] = useState(null); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + const { upload } = useApi(); + + const validateFile = useCallback( + async (file: File): Promise => { + // Type validation + if (!acceptedTypes.includes(file.type)) { + return { + valid: false, + error: `Invalid file type. Accepted: ${acceptedTypes.map((t) => t.replace('image/', '.')).join(', ')}`, + }; + } + + // Size validation + const maxSizeBytes = maxSizeMB * 1024 * 1024; + if (file.size > maxSizeBytes) { + return { + valid: false, + error: `File too large. Max size: ${maxSizeMB}MB`, + }; + } + + // Dimension validation + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(img.src); + if (img.width > maxDimension || img.height > maxDimension) { + resolve({ + valid: false, + error: `Image too large. Max dimensions: ${maxDimension}x${maxDimension}px`, + }); + } else { + resolve({ valid: true }); + } + }; + img.onerror = () => { + resolve({ valid: false, error: 'Invalid image file' }); + }; + img.src = URL.createObjectURL(file); + }); + }, + [acceptedTypes, maxSizeMB, maxDimension] + ); + + const generatePreview = useCallback((file: File): string => { + return URL.createObjectURL(file); + }, []); + + const handleFileSelect = useCallback( + async (file: File | null) => { + if (!file) return; + + setUploadState('validating'); + setError(null); + setProgress(0); + + const validation = await validateFile(file); + if (!validation.valid) { + setUploadState('error'); + setError(validation.error || 'Validation failed'); + onError?.(validation.error || 'Validation failed'); + return; + } + + const previewUrl = generatePreview(file); + setPreview(previewUrl); + setUploadState('uploading'); + + try { + // Simulate progress (replace with actual upload progress if your API supports it) + const progressInterval = setInterval(() => { + setProgress((prev) => { + if (prev >= 90) { + clearInterval(progressInterval); + return prev; + } + return prev + 10; + }); + }, 200); + + const response = await upload('/users/avatar', file, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + clearInterval(progressInterval); + setProgress(100); + setUploadState('success'); + + // Clean up preview after successful upload + setTimeout(() => { + setPreview(null); + setProgress(0); + setUploadState('idle'); + }, 1500); + + onSuccess?.(response.avatarUrl); + } catch (err) { + setUploadState('error'); + const errorMessage = err instanceof Error ? err.message : 'Upload failed'; + setError(errorMessage); + onError?.(errorMessage); + } + }, + [validateFile, generatePreview, upload, onSuccess, onError] + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0] || null; + handleFileSelect(file); + // Reset input so same file can be selected again + e.target.value = ''; + }, + [handleFileSelect] + ); + + const triggerFileSelect = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const clearError = useCallback(() => { + setError(null); + setUploadState('idle'); + setPreview(null); + setProgress(0); + }, []); + + return { + uploadState, + progress, + preview, + error, + fileInputRef, + handleInputChange, + triggerFileSelect, + handleFileSelect, + clearError, + }; +} \ No newline at end of file diff --git a/src/app/stores/useUserStore.ts b/src/app/stores/useUserStore.ts index 23ec4bd..2eb469e 100644 --- a/src/app/stores/useUserStore.ts +++ b/src/app/stores/useUserStore.ts @@ -16,16 +16,19 @@ import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; import { useGamificationStore } from "./useGamificationStore"; +import { create } from 'zustand'; // ─── Types ─────────────────────────────────────────────────────────────────── export interface User { id: string; + name: string; email: string; walletAddress?: string; kycVerified: boolean; /** ISO 8601 timestamp of when the session was established */ sessionStartedAt?: string; + avatarUrl: string | null; } interface UserState { @@ -52,6 +55,9 @@ interface UserActions { updateUser: (partial: Partial) => void; setLoading: (loading: boolean) => void; setError: (error: string | null) => void; + // New avatar actions + setAvatar: (avatarUrl: string) => void; + removeAvatar: () => void; } export type UserStore = UserState & UserActions; @@ -103,6 +109,16 @@ export const useUserStore = create()( ); }, + setAvatar: (avatarUrl) => + set((state) => ({ + user: state.user ? { ...state.user, avatarUrl } : null, + })), + + removeAvatar: () => + set((state) => ({ + user: state.user ? { ...state.user, avatarUrl: null } : null, + })), + setLoading: (isLoading) => set({ isLoading }, false, "user/setLoading"), setError: (error) => set({ error, isLoading: false }, false, "user/setError"),