diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index c80204f..9c2f02e 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -14,7 +14,7 @@ const signUpSchema = z.object({ }) const signInSchema = z.object({ - email: z.string().email(), + emailOrUsername: z.string().min(1), password: z.string().min(1) }) @@ -93,7 +93,7 @@ router.post('/signup', asyncHandler(async (req, res) => { /** * POST /api/auth/signin - * Sign in with email and password + * Sign in with email/username and password */ router.post('/signin', asyncHandler(async (req, res) => { const validationResult = signInSchema.safeParse(req.body) @@ -101,15 +101,40 @@ router.post('/signin', asyncHandler(async (req, res) => { throw createError(400, 'Invalid request data', 'VALIDATION_ERROR') } - const { email, password } = validationResult.data + const { emailOrUsername, password } = validationResult.data + + let email = emailOrUsername + + // If input doesn't contain @, treat it as username and look up email + if (!emailOrUsername.includes('@')) { + console.log(`๐Ÿ” Looking up email for username: ${emailOrUsername}`) + const { data: profile, error: profileError } = await supabase + .from('profiles') + .select('email') + .eq('username', emailOrUsername.toLowerCase()) + .single() + + if (profileError || !profile) { + console.log(`โŒ Username not found: ${emailOrUsername}`) + throw createError(401, 'Invalid username or password') + } + + email = profile.email + console.log(`โœ… Found email for username: ${emailOrUsername}`) + } + console.log(`๐Ÿ” Attempting to sign in with email: ${email}`) const { data, error } = await supabase.auth.signInWithPassword({ email, password }) if (error) { - throw createError(401, 'Invalid email or password') + console.log(`โŒ Sign in failed:`, error.message) + if (error.message.includes('Invalid login credentials')) { + throw createError(401, 'Invalid email/username or password') + } + throw createError(401, error.message) } if (!data.user || !data.session) { @@ -127,6 +152,8 @@ router.post('/signin', asyncHandler(async (req, res) => { throw createError(500, 'Failed to fetch user profile') } + console.log(`โœ… Sign in successful for user: ${profile.username}`) + res.json({ message: 'Signed in successfully', user: data.user, diff --git a/backend/src/routes/friends.ts b/backend/src/routes/friends.ts index 34b991f..b43de31 100644 --- a/backend/src/routes/friends.ts +++ b/backend/src/routes/friends.ts @@ -15,6 +15,141 @@ const searchFriendsSchema = z.object({ limit: z.number().min(1).max(20).optional().default(10) }) +/** + * GET /api/friends/suggestions + * Get friend suggestions based on habits, location, and occupation + */ +router.get('/suggestions', asyncHandler(async (req: AuthenticatedRequest, res) => { + if (!req.user) { + throw createError(401, 'Authentication required') + } + + const limit = parseInt(req.query.limit as string) || 10 + + console.log('๐Ÿ” Finding friend suggestions for user:', req.user.id) + + // Get current user's profile + const { data: currentProfile, error: profileError } = await supabase + .from('profiles') + .select('occupation, location') + .eq('id', req.user.id) + .single() + + if (profileError) { + throw createError(500, 'Failed to fetch user profile') + } + + // Get current user's habits + const { data: userHabits } = await supabase + .from('habits') + .select('category') + .eq('user_id', req.user.id) + .eq('is_active', true) + + const userHabitCategories = userHabits?.map(h => h.category) || [] + + // Get existing friendships to exclude them + const { data: existingFriendships } = await supabase + .from('friendships') + .select('requester_id, addressee_id') + .or(`requester_id.eq.${req.user.id},addressee_id.eq.${req.user.id}`) + + const excludeIds = [req.user.id] + existingFriendships?.forEach(f => { + const friendId = f.requester_id === req.user!.id ? f.addressee_id : f.requester_id + excludeIds.push(friendId) + }) + + // Find users with similar attributes + let query = supabase + .from('profiles') + .select('id, username, display_name, avatar_url, snap_score, current_streak, occupation, location') + .not('id', 'in', `(${excludeIds.join(',')})`) + .eq('ghost_mode', false) + + const suggestions: any[] = [] + + // First, try to find users with matching occupation or location + if (currentProfile.occupation || currentProfile.location) { + let orConditions = [] + if (currentProfile.occupation) { + orConditions.push(`occupation.eq.${currentProfile.occupation}`) + } + if (currentProfile.location) { + orConditions.push(`location.ilike.%${currentProfile.location}%`) + } + + const { data: matchingUsers } = await query + .or(orConditions.join(',')) + .order('snap_score', { ascending: false }) + .limit(limit * 2) + + if (matchingUsers) { + suggestions.push(...matchingUsers) + } + } + + // Then find users with similar habits + if (userHabitCategories.length > 0) { + const { data: habitsData } = await supabase + .from('habits') + .select('user_id, category') + .in('category', userHabitCategories) + .eq('is_active', true) + .not('user_id', 'in', `(${excludeIds.join(',')})`) + + if (habitsData) { + // Count matching habits per user + const habitMatches: Record = {} + habitsData.forEach(h => { + habitMatches[h.user_id] = (habitMatches[h.user_id] || 0) + 1 + }) + + // Get top matching users + const topMatchUserIds = Object.entries(habitMatches) + .sort(([, a], [, b]) => b - a) + .slice(0, limit) + .map(([userId]) => userId) + + if (topMatchUserIds.length > 0) { + const { data: habitMatchUsers } = await supabase + .from('profiles') + .select('id, username, display_name, avatar_url, snap_score, current_streak, occupation, location') + .in('id', topMatchUserIds) + .eq('ghost_mode', false) + + if (habitMatchUsers) { + suggestions.push(...habitMatchUsers) + } + } + } + } + + // Remove duplicates and limit results + const uniqueSuggestions = Array.from( + new Map(suggestions.map(s => [s.id, s])).values() + ).slice(0, limit) + + console.log('โœ… Found suggestions:', uniqueSuggestions.length) + + res.json({ + suggestions: uniqueSuggestions.map(user => ({ + id: user.id, + username: user.username, + displayName: user.display_name, + avatarUrl: user.avatar_url, + snapScore: user.snap_score, + currentStreak: user.current_streak, + occupation: user.occupation, + location: user.location, + matchReason: currentProfile.occupation === user.occupation ? 'Same occupation' : + currentProfile.location && user.location?.includes(currentProfile.location) ? 'Same location' : + 'Similar habits' + })), + total: uniqueSuggestions.length + }) +})) + /** * GET /api/friends/search * Search for users to add as friends diff --git a/backend/src/routes/profiles.ts b/backend/src/routes/profiles.ts index 3ad66f0..2f08b6c 100644 --- a/backend/src/routes/profiles.ts +++ b/backend/src/routes/profiles.ts @@ -14,6 +14,33 @@ const updateProfileSchema = z.object({ locationEnabled: z.boolean().optional() }) +/** + * GET /api/profiles/username-available/:username + * Check if username is available (PUBLIC endpoint - no auth required) + */ +router.get('/username-available/:username', asyncHandler(async (req: express.Request, res) => { + const { username } = req.params + + if (!username || username.length < 3) { + return res.json({ available: false, message: 'Username must be at least 3 characters' }) + } + + const { data: existingProfile, error } = await supabase + .from('profiles') + .select('id') + .eq('username', username.toLowerCase()) + .maybeSingle() + + if (error) { + throw createError(500, 'Failed to check username availability') + } + + res.json({ + available: !existingProfile, + message: existingProfile ? 'Username is already taken' : 'Username is available' + }) +})) + /** * GET /api/profiles/me * Get current user's profile diff --git a/backend/src/server.ts b/backend/src/server.ts index 9223fc5..4d68786 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -64,6 +64,57 @@ app.use('/uploads', express.static('uploads')) // Rate limiting app.use(rateLimiter) +// Root endpoint - API documentation +app.get('/', (req, res) => { + res.json({ + name: 'Snapbit API', + version: '1.0.0', + description: 'Habit tracking with friends - Backend API', + status: 'running', + timestamp: new Date().toISOString(), + endpoints: { + health: 'GET /health', + auth: { + signup: 'POST /api/auth/signup', + login: 'POST /api/auth/login', + logout: 'POST /api/auth/logout' + }, + profiles: { + me: 'GET /api/profiles/me', + update: 'PUT /api/profiles/me', + byUsername: 'GET /api/profiles/:username' + }, + habits: { + list: 'GET /api/habits', + create: 'POST /api/habits', + update: 'PUT /api/habits/:id', + delete: 'DELETE /api/habits/:id' + }, + snaps: { + create: 'POST /api/snaps', + list: 'GET /api/snaps', + verify: 'POST /api/snaps/:id/verify' + }, + friends: { + list: 'GET /api/friends', + request: 'POST /api/friends/request', + accept: 'PUT /api/friends/:id/accept', + streaks: 'GET /api/friends/streaks' + }, + chat: { + rooms: 'GET /api/chat/rooms', + messages: 'GET /api/chat/:roomId/messages', + send: 'POST /api/chat/:roomId/messages' + }, + leaderboard: 'GET /api/leaderboard', + snapFeed: 'GET /api/snap-feed', + streaks: 'GET /api/streaks' + }, + websocket: 'ws://localhost:' + PORT, + docs: 'https://github.com/yashudeveloper/Snapbit' + }) +}) + // Health check endpoint app.get('/health', (req, res) => { res.json({ @@ -76,6 +127,9 @@ app.get('/health', (req, res) => { // API routes app.use('/api/auth', authRoutes) +// Public profile routes (username availability check) +app.get('/api/profiles/username-available/:username', profileRoutes) +// Protected profile routes app.use('/api/profiles', authMiddleware, profileRoutes) app.use('/api/habits', authMiddleware, habitRoutes) app.use('/api/snaps', authMiddleware, snapRoutes) diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..9e534dc --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -0,0 +1,43 @@ +import { useEffect } from 'react' + +interface ToastProps { + message: string + type: 'success' | 'error' | 'info' + onClose: () => void + duration?: number +} + +export default function Toast({ message, type, onClose, duration = 4000 }: ToastProps) { + useEffect(() => { + const timer = setTimeout(onClose, duration) + return () => clearTimeout(timer) + }, [onClose, duration]) + + const bgColor = { + success: 'bg-green-500', + error: 'bg-red-500', + info: 'bg-blue-500' + }[type] + + const icon = { + success: 'โœ…', + error: 'โŒ', + info: 'โ„น๏ธ' + }[type] + + return ( +
+
+ {icon} +

{message}

+ +
+
+ ) +} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 0471e4f..641519f 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -7,7 +7,7 @@ interface AuthContextType { profile: Profile | null session: Session | null loading: boolean - signIn: (email: string, password: string) => Promise<{ error?: any }> + signIn: (emailOrUsername: string, password: string) => Promise<{ error?: any }> signUp: (email: string, password: string, username: string, displayName: string, additionalData?: { dateOfBirth?: string gender?: string @@ -39,109 +39,38 @@ export function AuthProvider({ children }: AuthProviderProps) { const [session, setSession] = useState(null) const [loading, setLoading] = useState(true) - // FUCK SUPABASE CLIENT - RAW FETCH API ONLY! + // Fetch user profile from Supabase const fetchProfile = async (userId: string) => { - console.log('๐Ÿš€ FUCK IT - DIRECT API CALL FOR:', userId) + console.log('๐Ÿ” Fetching profile for user:', userId) - // HARDCODED TOKEN FROM STORAGE - let token = null try { - const sessionData = localStorage.getItem('sb-idxahvulzazdjhikehvu-auth-token') - if (sessionData) { - const parsed = JSON.parse(sessionData) - token = parsed?.access_token - console.log('๐Ÿ”‘ Token from localStorage:', token ? 'FOUND' : 'NOT FOUND') - } - } catch (e) { - console.log('โŒ Failed to get token from localStorage') - } - - // If no token from localStorage, try session - if (!token) { - try { - const { data: { session } } = await supabase.auth.getSession() - token = session?.access_token - console.log('๐Ÿ”‘ Token from session:', token ? 'FOUND' : 'NOT FOUND') - } catch (e) { - console.log('โŒ Failed to get session') - } - } + // Use Supabase client directly - it handles auth automatically + const { data, error } = await supabase + .from('profiles') + .select('*') + .eq('id', userId) + .single() - // FUCK IT - TRY WITHOUT TOKEN FIRST - console.log('๐Ÿ“ก ATTEMPTING API CALL WITHOUT AUTH...') - try { - const url = `https://idxahvulzazdjhikehvu.supabase.co/rest/v1/profiles?id=eq.${userId}&select=*` - console.log('๐ŸŒ URL:', url) - - const response = await fetch(url, { - method: 'GET', - headers: { - 'apikey': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlkeGFodnVsemF6ZGpoaWtlaHZ1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjIyNzMyNTMsImV4cCI6MjA3Nzg0OTI1M30.Oxu5_TEYJevW56ZkSFv7AbbFBQGF2d1f74ypDTpgj1I', - 'Content-Type': 'application/json', - 'Accept': 'application/json' - } - }) - - console.log('๐Ÿ“ก Response status (no auth):', response.status) - - if (response.ok) { - const data = await response.json() - console.log('๐Ÿ“ฆ SUCCESS WITHOUT AUTH:', data) - - if (data && data.length > 0) { - console.log('โœ… PROFILE LOADED (NO AUTH)!') - setProfile(data[0]) - setLoading(false) - return - } + if (error) { + console.error('โŒ Profile fetch error:', error) + throw error } - } catch (e) { - console.log('โŒ No auth attempt failed:', e) - } - // NOW TRY WITH TOKEN - if (token) { - console.log('๐Ÿ“ก ATTEMPTING API CALL WITH AUTH TOKEN...') - try { - const url = `https://idxahvulzazdjhikehvu.supabase.co/rest/v1/profiles?id=eq.${userId}&select=*` - - const response = await fetch(url, { - method: 'GET', - headers: { - 'apikey': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlkeGFodnVsemF6ZGpoaWtlaHZ1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjIyNzMyNTMsImV4cCI6MjA3Nzg0OTI1M30.Oxu5_TEYJevW56ZkSFv7AbbFBQGF2d1f74ypDTpgj1I', - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Accept': 'application/json' - } - }) - - console.log('๐Ÿ“ก Response status (with auth):', response.status) - - if (response.ok) { - const data = await response.json() - console.log('๐Ÿ“ฆ SUCCESS WITH AUTH:', data) - - if (data && data.length > 0) { - console.log('โœ… PROFILE LOADED (WITH AUTH)!') - setProfile(data[0]) - setLoading(false) - return - } - } else { - const errorText = await response.text() - console.error('โŒ AUTH ERROR:', response.status, errorText) - } - } catch (e) { - console.error('๐Ÿ’ฅ AUTH ATTEMPT FAILED:', e) + if (data) { + console.log('โœ… Profile loaded successfully:', data.username) + setProfile(data) + } else { + console.error('โŒ No profile found for user') } + } catch (error) { + console.error('๐Ÿ’ฅ Failed to fetch profile:', error) + } finally { + setLoading(false) } - - console.error('๐Ÿ’€ ALL ATTEMPTS FAILED - NO PROFILE LOADED') - setLoading(false) } useEffect(() => { - console.log('๐Ÿš€ AuthProvider INIT') + console.log('๐Ÿš€ AuthProvider initializing...') let isActive = true const initialize = async () => { @@ -149,20 +78,20 @@ export function AuthProvider({ children }: AuthProviderProps) { // Get session console.log('1๏ธโƒฃ Getting session...') const { data: { session } } = await supabase.auth.getSession() - console.log('2๏ธโƒฃ Session:', session ? 'โœ… EXISTS' : 'โŒ NONE') + console.log('2๏ธโƒฃ Session:', session ? `โœ… User: ${session.user?.email}` : 'โŒ No session') if (!isActive) return - setSession(session) - setUser(session?.user ?? null) + setSession(session) + setUser(session?.user ?? null) - if (session?.user) { - console.log('3๏ธโƒฃ Calling fetchProfile...') + if (session?.user) { + console.log('3๏ธโƒฃ Fetching profile for user:', session.user.id) await fetchProfile(session.user.id) - } else { - console.log('3๏ธโƒฃ No user, setting loading false') - setLoading(false) - } + } else { + console.log('3๏ธโƒฃ No user session, ready for login/signup') + setLoading(false) + } } catch (err) { console.error('๐Ÿ’ฅ Init error:', err) if (isActive) setLoading(false) @@ -174,41 +103,90 @@ export function AuthProvider({ children }: AuthProviderProps) { // Auth state listener const { data: { subscription } } = supabase.auth.onAuthStateChange( async (event, session) => { - console.log('๐Ÿ”” Auth event:', event) + console.log('๐Ÿ”” Auth event:', event, session?.user?.email || 'no user') if (!isActive) return - setSession(session) - setUser(session?.user ?? null) + setSession(session) + setUser(session?.user ?? null) if (event === 'SIGNED_IN' && session?.user) { - await fetchProfile(session.user.id) + console.log('โœ… User signed in, fetching profile...') + await fetchProfile(session.user.id) } else if (event === 'SIGNED_OUT') { - setProfile(null) - setLoading(false) - } + console.log('๐Ÿ‘‹ User signed out') + setProfile(null) + setLoading(false) + } } ) return () => { - console.log('๐Ÿงน Cleanup') + console.log('๐Ÿงน AuthProvider cleanup') isActive = false subscription.unsubscribe() } }, []) - const signIn = async (email: string, password: string) => { + const signIn = async (emailOrUsername: string, password: string) => { try { setLoading(true) + console.log('๐Ÿ” SignIn attempt:', emailOrUsername) + + let email = emailOrUsername + + // If input doesn't contain @, treat it as username and look up email + if (!emailOrUsername.includes('@')) { + console.log('๐Ÿ” Looking up email for username:', emailOrUsername) + const { data, error } = await supabase + .from('profiles') + .select('email') + .eq('username', emailOrUsername.toLowerCase()) + .single() + + if (error || !data) { + console.log('โŒ Username not found') + setLoading(false) + return { + error: { + message: 'Username not found. Please check your username or try signing in with email.', + status: 401 + } + } + } + + email = data.email + console.log('โœ… Found email for username') + } + + console.log('๐Ÿ” Attempting sign in with email:', email) const { error } = await supabase.auth.signInWithPassword({ email, password, }) - return { error } - } catch (error) { - return { error } - } finally { + + if (error) { + console.log('โŒ Sign in failed:', error.message) + setLoading(false) + return { + error: { + message: 'Invalid password. Please check your password and try again.', + status: 401 + } + } + } + + console.log('โœ… Sign in successful!') + return { error: null } + } catch (error: any) { + console.error('๐Ÿ’ฅ Sign in exception:', error) setLoading(false) + return { + error: { + message: error?.message || 'Network error. Please check your connection and try again.', + status: 500 + } + } } } diff --git a/frontend/src/screens/HabitsScreen.tsx b/frontend/src/screens/HabitsScreen.tsx index 4d70365..2936428 100644 --- a/frontend/src/screens/HabitsScreen.tsx +++ b/frontend/src/screens/HabitsScreen.tsx @@ -163,21 +163,22 @@ function CreateHabitModal({ onClose, onCreate }: CreateHabitModalProps) { } return ( -
-
+
+
{/* Header */} -
+

Create New Habit

{/* Form */} -
+ {/* Title */}
@@ -258,13 +259,15 @@ function CreateHabitModal({ onClose, onCreate }: CreateHabitModalProps) {
{/* Submit Button */} - +
+ +
diff --git a/frontend/src/screens/LoginScreen.tsx b/frontend/src/screens/LoginScreen.tsx index 65ed514..91021ac 100644 --- a/frontend/src/screens/LoginScreen.tsx +++ b/frontend/src/screens/LoginScreen.tsx @@ -2,16 +2,18 @@ import React, { useState } from 'react' import { Eye, EyeOff, Mail, Lock } from 'lucide-react' import { useAuth } from '../contexts/AuthContext' import OnboardingScreen from './OnboardingScreen' +import Toast from '../components/Toast' export default function LoginScreen() { const { signIn, loading } = useAuth() const [showOnboarding, setShowOnboarding] = useState(false) const [showPassword, setShowPassword] = useState(false) const [formData, setFormData] = useState({ - email: '', + emailOrUsername: '', password: '' }) const [error, setError] = useState('') + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null) // Show onboarding screen if user wants to sign up if (showOnboarding) { @@ -21,11 +23,29 @@ export default function LoginScreen() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError('') + + console.log('๐Ÿ” Attempting login with:', formData.emailOrUsername) - const { error } = await signIn(formData.email, formData.password) + const { error } = await signIn(formData.emailOrUsername, formData.password) - if (error) { - setError(error.message || 'Failed to sign in') + if (error) { + console.error('โŒ Login failed:', error) + let errorMessage = 'Failed to sign in. Please check your credentials.' + + // Parse error message + if (error.message.includes('Invalid')) { + errorMessage = 'โŒ Invalid username/email or password. Please try again.' + } else if (error.message.includes('not found')) { + errorMessage = 'โŒ Account not found. Please check your username/email.' + } else if (error.message) { + errorMessage = `โŒ ${error.message}` + } + + setError(errorMessage) + setToast({ message: errorMessage, type: 'error' }) + } else { + console.log('โœ… Login successful!') + setToast({ message: 'โœจ Welcome back! Successfully signed in!', type: 'success' }) } } @@ -36,31 +56,40 @@ export default function LoginScreen() { return (
+ {/* Toast Notification */} + {toast && ( + setToast(null)} + /> + )} + {/* Header */} -
- {/* Logo */} -
-
-
-
+
+ {/* Logo - Centered */} +
+
+
+
-

SnapHabit

-

+

SnapHabit

+

AI-powered habit tracking through snaps

{/* Form */}
- {/* Email */} + {/* Email or Username */}
handleInputChange('email', e.target.value)} + type="text" + placeholder="Email or Username" + value={formData.emailOrUsername} + onChange={(e) => handleInputChange('emailOrUsername', e.target.value)} className="w-full pl-12 pr-4 py-4 bg-snapchat-gray-800 text-white rounded-2xl border border-snapchat-gray-700 focus:border-snapchat-yellow focus:outline-none transition-colors" required /> diff --git a/frontend/src/screens/OnboardingScreen.tsx b/frontend/src/screens/OnboardingScreen.tsx index 32dc39d..c1225b7 100644 --- a/frontend/src/screens/OnboardingScreen.tsx +++ b/frontend/src/screens/OnboardingScreen.tsx @@ -1,11 +1,14 @@ import React, { useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' -import { Eye, EyeOff, ChevronRight, Calendar, MapPin, ArrowLeft } from 'lucide-react' +import { Eye, EyeOff, ChevronRight, Calendar, MapPin, ArrowLeft, Check, X, Loader } from 'lucide-react' import { useAuth } from '../contexts/AuthContext' +import Toast from '../components/Toast' +import { apiClient } from '../lib/api' interface OnboardingData { // Step 1: Account Setup name: string + username: string email: string password: string @@ -49,8 +52,11 @@ export default function OnboardingScreen({ onBack }: OnboardingScreenProps) { const [currentStep, setCurrentStep] = useState(1) const [showPassword, setShowPassword] = useState(false) const [error, setError] = useState('') + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null) + const [usernameStatus, setUsernameStatus] = useState<'idle' | 'checking' | 'available' | 'taken' | 'error'>('idle') const [data, setData] = useState({ name: '', + username: '', email: '', password: '', dateOfBirth: '', @@ -63,6 +69,56 @@ export default function OnboardingScreen({ onBack }: OnboardingScreenProps) { const updateData = (field: keyof OnboardingData, value: string) => { setData(prev => ({ ...prev, [field]: value })) if (error) setError('') + + // Auto-generate username from name if name field changes + if (field === 'name' && !data.username) { + const generatedUsername = value.toLowerCase().replace(/[^a-z0-9]/g, '') + setData(prev => ({ ...prev, username: generatedUsername })) + if (generatedUsername.length >= 3) { + checkUsernameAvailability(generatedUsername) + } + } + + // Check username availability when username changes + if (field === 'username' && value.length >= 3) { + checkUsernameAvailability(value) + } + } + + // Debounced username availability check + const checkUsernameAvailability = React.useCallback( + debounce(async (username: string) => { + if (username.length < 3) { + setUsernameStatus('idle') + return + } + + setUsernameStatus('checking') + try { + console.log('๐Ÿ” Checking username availability:', username) + const response = await apiClient.get<{ available: boolean; message?: string }>(`/profiles/username-available/${username}`) + console.log('โœ… Username check response:', response) + setUsernameStatus(response.available ? 'available' : 'taken') + } catch (error) { + console.error('โŒ Error checking username:', error) + setUsernameStatus('error') + // Don't show toast - error will be shown below the field + } + }, 300), + [] + ) + + // Debounce helper function + function debounce any>(func: T, wait: number) { + let timeout: NodeJS.Timeout + return function executedFunction(...args: Parameters) { + const later = () => { + clearTimeout(timeout) + func(...args) + } + clearTimeout(timeout) + timeout = setTimeout(later, wait) + } } const nextStep = () => { @@ -80,6 +136,18 @@ export default function OnboardingScreen({ onBack }: OnboardingScreenProps) { setError('Name is required') return false } + if (!data.username.trim()) { + setError('Username is required') + return false + } + if (data.username.length < 3) { + setError('Username must be at least 3 characters') + return false + } + if (usernameStatus === 'taken') { + setError('Username is already taken') + return false + } if (!data.email.trim()) { setError('Email is required') return false @@ -123,14 +191,19 @@ export default function OnboardingScreen({ onBack }: OnboardingScreenProps) { const handleFinish = async () => { if (!validateCurrentStep()) return + console.log('๐Ÿš€ Starting signup process...', { + email: data.email, + username: data.username, + name: data.name + }) + + setToast({ message: 'โณ Creating your account...', type: 'info' }) + try { - // Create username from name (lowercase, no spaces) - const username = data.name.toLowerCase().replace(/[^a-z0-9]/g, '') - const { error } = await signUp( data.email, data.password, - username, + data.username, // Use the username from the form data.name, { dateOfBirth: data.dateOfBirth, @@ -142,16 +215,49 @@ export default function OnboardingScreen({ onBack }: OnboardingScreenProps) { ) if (error) { - setError(error.message || 'Failed to create account') + console.error('โŒ Signup error:', error) + let errorMessage = error.message || 'Failed to create account' + + // Make error messages more user-friendly + if (errorMessage.includes('already')) { + errorMessage = 'โŒ This email or username is already registered. Please try logging in or use different credentials.' + } else if (errorMessage.includes('Invalid')) { + errorMessage = 'โŒ Please check your information and try again.' + } else if (errorMessage.includes('network') || errorMessage.includes('fetch')) { + errorMessage = 'โŒ Network error. Please check your connection and try again.' + } else { + errorMessage = `โŒ ${errorMessage}` + } + + setError(errorMessage) + setToast({ message: errorMessage, type: 'error' }) } else { + console.log('โœ… Signup successful!') // Success! Add vibration feedback and excitement if ('vibrate' in navigator) { navigator.vibrate([100, 50, 100]) } - // AuthContext will handle the redirect to camera screen + setToast({ message: '๐ŸŽ‰ Account created successfully! Welcome to SnapHabit!', type: 'success' }) + // Wait a bit to show the success message before redirect + setTimeout(() => { + console.log('โœ… Redirecting to app...') + }, 2000) // Increased from 1500ms to 2000ms to ensure visibility } } catch (err: any) { - setError(err.message || 'Something went wrong') + console.error('๐Ÿ’ฅ Signup exception:', err) + let errorMessage = err.message || 'Something went wrong' + + // Make error messages more user-friendly + if (errorMessage.includes('already')) { + errorMessage = 'โŒ This email or username is already registered. Please try logging in.' + } else if (errorMessage.includes('network') || errorMessage.includes('fetch')) { + errorMessage = 'โŒ Network error. Please check your connection and try again.' + } else { + errorMessage = `โŒ ${errorMessage}` + } + + setError(errorMessage) + setToast({ message: errorMessage, type: 'error' }) } } @@ -185,6 +291,7 @@ export default function OnboardingScreen({ onBack }: OnboardingScreenProps) { >

CREATE ACCOUNT

+ {/* Name Field */}
+ {/* Username Field with Availability Check */} +
+
+ { + const value = e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '') + updateData('username', value) + }} + className={`w-full px-6 py-4 pr-12 bg-white/10 text-white rounded-2xl border transition-colors placeholder-gray-400 focus:outline-none ${ + usernameStatus === 'available' ? 'border-green-500' : + usernameStatus === 'taken' ? 'border-red-500' : + 'border-gray-600 focus:border-snapchat-yellow' + }`} + maxLength={30} + minLength={3} + /> + {/* Username Status Indicator */} +
+ {usernameStatus === 'checking' && ( + + )} + {usernameStatus === 'available' && ( + + )} + {usernameStatus === 'taken' && ( + + )} + {usernameStatus === 'error' && ( + + )} +
+
+ {/* Username Feedback - Compact, no extra space */} + {data.username.length > 0 && ( +
+ {data.username.length < 3 ? ( +

+ Username must be at least 3 characters +

+ ) : usernameStatus === 'available' ? ( +

+ โœ“ Username is available +

+ ) : usernameStatus === 'taken' ? ( +

+ โœ— Username is already taken +

+ ) : usernameStatus === 'error' ? ( +

+ โš ๏ธ Could not check availability. Please try again. +

+ ) : null} +
+ )} +
+ + {/* Email Field */}
+ {/* Password Field */}
setShowPassword(!showPassword)} className="absolute right-4 top-1/2 transform -translate-y-1/2 text-gray-400" + aria-label="Toggle password visibility" > {showPassword ? : } @@ -350,23 +519,33 @@ export default function OnboardingScreen({ onBack }: OnboardingScreenProps) { } return ( -
+
+ {/* Toast Notification */} + {toast && ( + setToast(null)} + /> + )} + {/* Back Button */} {onBack && currentStep === 1 && (
)} -
+
{/* Logo */} -
-
+
+
diff --git a/frontend/src/screens/ProfileScreen.tsx b/frontend/src/screens/ProfileScreen.tsx index 313b8d8..b8bb93c 100644 --- a/frontend/src/screens/ProfileScreen.tsx +++ b/frontend/src/screens/ProfileScreen.tsx @@ -1,25 +1,110 @@ -import React, { useState } from 'react' -import { Settings, Trophy, Target, Calendar, Share, LogOut, Edit3 } from 'lucide-react' +import React, { useState, useRef } from 'react' +import { Settings, Trophy, Target, Calendar, Share, LogOut, Edit3, Check, X, Loader, Camera } from 'lucide-react' +import { useNavigate } from 'react-router-dom' import { useAuth } from '../contexts/AuthContext' import { useHabits } from '../contexts/HabitsContext' +import Toast from '../components/Toast' +import { apiClient } from '../lib/api' export default function ProfileScreen() { - const { profile, signOut } = useAuth() - const { habits, snaps, streaks } = useHabits() + const { profile, signOut, updateProfile } = useAuth() + const { habits, snaps } = useHabits() + const navigate = useNavigate() const [showSettings, setShowSettings] = useState(false) + const [isEditing, setIsEditing] = useState(false) + const [editData, setEditData] = useState({ + displayName: profile?.display_name || '', + username: profile?.username || '', + avatarUrl: profile?.avatar_url || '', + dateOfBirth: profile?.date_of_birth || '', + gender: profile?.gender || '', + occupation: profile?.occupation || '', + location: profile?.location || '' + }) + const [usernameStatus, setUsernameStatus] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle') + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null) + const [isSaving, setIsSaving] = useState(false) + const fileInputRef = useRef(null) if (!profile) return null + // Handle image upload + const handleImageUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + // Create preview URL + const reader = new FileReader() + reader.onloadend = () => { + setEditData({ ...editData, avatarUrl: reader.result as string }) + } + reader.readAsDataURL(file) + } + } + + // Check username availability (debounced) + const checkUsernameAvailability = async (username: string) => { + if (username === profile.username || username.length < 3) { + setUsernameStatus('idle') + return + } + + setUsernameStatus('checking') + try { + const response = await apiClient.get<{ available: boolean }>(`/profiles/username-available/${username}`) + setUsernameStatus(response.available ? 'available' : 'taken') + } catch (error) { + console.error('Error checking username:', error) + setUsernameStatus('idle') + } + } + + const handleSaveProfile = async () => { + if (usernameStatus === 'taken') { + setToast({ message: 'Username is already taken', type: 'error' }) + return + } + + setIsSaving(true) + try { + const { error } = await updateProfile({ + display_name: editData.displayName, + username: editData.username.toLowerCase(), + avatar_url: editData.avatarUrl, + date_of_birth: editData.dateOfBirth, + gender: editData.gender as 'male' | 'female' | 'other' | undefined, + occupation: editData.occupation, + location: editData.location + }) + + if (error) { + setToast({ message: error.message || 'Failed to update profile', type: 'error' }) + } else { + setToast({ message: 'โœ… Profile updated successfully!', type: 'success' }) + setIsEditing(false) + } + } catch (error: any) { + setToast({ message: error?.message || 'Failed to update profile', type: 'error' }) + } finally { + setIsSaving(false) + } + } + + const handleCancelEdit = () => { + setEditData({ + displayName: profile.display_name, + username: profile.username, + avatarUrl: profile.avatar_url || '', + dateOfBirth: profile.date_of_birth || '', + gender: profile.gender || '', + occupation: profile.occupation || '', + location: profile.location || '' + }) + setIsEditing(false) + setUsernameStatus('idle') + } + const approvedSnaps = snaps.filter(snap => snap.status === 'approved') const totalHabits = habits.length - const activeStreaks = habits.filter(habit => { - const todayStreak = streaks.find(s => - s.habit_id === habit.id && - s.date === new Date().toISOString().split('T')[0] && - s.completed - ) - return todayStreak - }).length const stats = [ { label: 'Snap Score', value: profile.snap_score, icon: Trophy, color: 'text-snapchat-yellow' }, @@ -30,72 +115,250 @@ export default function ProfileScreen() { return (
+ {/* Toast Notification */} + {toast && ( + setToast(null)} + /> + )} + {/* Header */}
-

Profile

- +

Profile

+
+ {isEditing ? ( + <> + + + + ) : ( + + )} +
{/* Profile Content */} -
+
{/* Profile Header */} -
+
{/* Avatar */}
-
- {profile.avatar_url ? ( +
+ {(isEditing ? editData.avatarUrl : profile.avatar_url) ? ( {profile.display_name} ) : ( - + {profile.display_name.charAt(0).toUpperCase()} )}
- + {isEditing ? ( + <> + + + + ) : ( + + )}
- {/* Name & Username */} -

{profile.display_name}

-

@{profile.username}

+ {/* Name & Username - Editable */} + {isEditing ? ( +
+ {/* Display Name */} + setEditData({ ...editData, displayName: e.target.value })} + className="w-full px-4 py-2 bg-snapchat-gray-800 text-white rounded-xl border border-snapchat-gray-700 focus:border-snapchat-yellow focus:outline-none text-center font-bold text-lg sm:text-xl" + placeholder="Display Name" + maxLength={50} + /> + + {/* Username */} +
+ { + const value = e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '') + setEditData({ ...editData, username: value }) + if (value.length >= 3) { + checkUsernameAvailability(value) + } + }} + className="w-full px-4 py-2 pr-10 bg-snapchat-gray-800 text-white rounded-xl border border-snapchat-gray-700 focus:border-snapchat-yellow focus:outline-none text-center" + placeholder="username" + maxLength={30} + minLength={3} + /> + {/* Username Status Indicator */} +
+ {usernameStatus === 'checking' && ( + + )} + {usernameStatus === 'available' && ( + + )} + {usernameStatus === 'taken' && ( + + )} +
+
+ {usernameStatus === 'taken' && ( +

Username is already taken

+ )} + + {/* Date of Birth */} + setEditData({ ...editData, dateOfBirth: e.target.value })} + className="w-full px-4 py-2 bg-snapchat-gray-800 text-white rounded-xl border border-snapchat-gray-700 focus:border-snapchat-yellow focus:outline-none text-center" + placeholder="Date of Birth" + /> + + {/* Gender */} + + + {/* Occupation */} + + + {/* Location */} + setEditData({ ...editData, location: e.target.value })} + className="w-full px-4 py-2 bg-snapchat-gray-800 text-white rounded-xl border border-snapchat-gray-700 focus:border-snapchat-yellow focus:outline-none text-center" + placeholder="Location" + maxLength={100} + /> +
+ ) : ( + <> +

{profile.display_name}

+

@{profile.username}

+ {/* Show additional profile info */} +
+ {profile.occupation &&

{profile.occupation}

} + {profile.location &&

๐Ÿ“ {profile.location}

} +
+ + )} {/* Action Buttons */} -
- - -
+ {!isEditing && ( +
+ + +
+ )}
{/* Stats Grid */} -
-

Statistics

-
+
+

Statistics

+
{stats.map((stat) => { const IconComponent = stat.icon return (
- -
{stat.value}
-
{stat.label}
+ +
{stat.value}
+
{stat.label}
) })} @@ -103,20 +366,20 @@ export default function ProfileScreen() {
{/* Recent Activity */} -
-

Recent Activity

+
+

Recent Activity

{approvedSnaps.length === 0 ? (
- -

No snaps yet

-

Start tracking habits to see your progress!

+ +

No snaps yet

+

Start tracking habits to see your progress!

) : ( -
+
{approvedSnaps.slice(0, 9).map((snap) => (
-
- {/* Header */} -
-

Settings

+
setShowSettings(false)} + > +
e.stopPropagation()} + > + {/* Header - Sticky */} +
+

Settings

- {/* Settings Options */} -
- - - + {/* Sign Out Button - Inside scroll area */}