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 */}
+
);
-}
+}
\ 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 ? (
+
+ ) : (
+
{initials}
+ )}
+
+ {/* Upload Progress Overlay */}
+ {isUploading && (
+
+ )}
+
+ {/* 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"),