From 586bd1d0a8e359a6934dcd171279dba1c6733003 Mon Sep 17 00:00:00 2001 From: Jakob Philippe <71989771+jakobphilippe@users.noreply.github.com> Date: Wed, 17 Apr 2024 20:40:19 -0400 Subject: [PATCH] Profile page / Follower + Following (#49) * profile page followers and following * profile pages complete, still needs updates to work fully with clerk users * backend changes * remove console logs * linting fix * use clerk first name last name for user profile * remove activity on follower profiles * small tweaks * fix profile image * onboarding fix --------- Co-authored-by: Jakob Philippe Co-authored-by: Nathan Co-authored-by: Ania Misiorek --- backend/src/db/migrations/1_USER_V1.sql | 2 +- backend/src/db/migrations/2_FOLLOWING_V1.sql | 4 +- .../src/db/migrations/5_USER_PORTFOLIO_V1.sql | 2 +- backend/src/models/user.go | 2 +- backend/src/services/etrade.go | 4 + frontend/components/ProfileBanner.tsx | 119 +++++++++++------- frontend/components/UserItem.tsx | 25 ++++ frontend/pages/AuthPage.tsx | 4 +- frontend/pages/Followers.tsx | 44 +++++++ frontend/pages/Profile.tsx | 28 ++--- .../reducers/onboarding/onboardingReducer.ts | 2 +- frontend/router/BottomNavBar.tsx | 5 +- frontend/router/ProfileNavigation.tsx | 29 +++++ frontend/services/followers.ts | 17 +++ frontend/services/users.ts | 2 - frontend/types/navigationTypes.ts | 5 + frontend/types/types.d.ts | 81 ++++++++++++ 17 files changed, 303 insertions(+), 72 deletions(-) create mode 100644 frontend/components/UserItem.tsx create mode 100644 frontend/pages/Followers.tsx create mode 100644 frontend/router/ProfileNavigation.tsx create mode 100644 frontend/services/followers.ts diff --git a/backend/src/db/migrations/1_USER_V1.sql b/backend/src/db/migrations/1_USER_V1.sql index d7f6de4a..471fee5b 100644 --- a/backend/src/db/migrations/1_USER_V1.sql +++ b/backend/src/db/migrations/1_USER_V1.sql @@ -18,6 +18,6 @@ VALUES ('user_2chL8dX6HdbBAuvu3DDM9f9NzKK', 'Ania', 'Misiorek', 'ania', 'https://ca.slack-edge.com/T2CHL6FEG-U05QP4M4M3P-349be7323f07-512'), ('user_2cpFbBLPGkPbszijtQneek7ZJxg', 'Leroy', 'Shaigorodsky', 'leroy', 'https://ca.slack-edge.com/T2CHL6FEG-U040ST08HM1-c3d453828123-512'), ('user_2dv5XFsCMYc4qLcsAnEJ1aUbxnk', 'Cam', 'Plume', 'campd10', 'https://ca.slack-edge.com/T2CHL6FEG-U06DCDZ3FB8-1c488c509f95-512'), - ('user_2cwGfu9zcjsbxq5Lp8gy2rkVNlc', 'Nathan', 'Jung', 'nathan', 'https://ca.slack-edge.com/T2CHL6FEG-U05QL55RDBQ-8fd6c3499cac-512'), + ('user_2fFVsSf4viW9pjx6Sd5dxEgutJ1', 'Nathan', 'Jung', 'nathan', 'https://ca.slack-edge.com/T2CHL6FEG-U05QL55RDBQ-8fd6c3499cac-512'), ('user_2dvSY6HpxotzWCfch5m4a4OVpAK', 'Aryan', 'Kale', 'aryankale', 'https://cdn-icons-png.freepik.com/256/552/552848.png'); diff --git a/backend/src/db/migrations/2_FOLLOWING_V1.sql b/backend/src/db/migrations/2_FOLLOWING_V1.sql index 65925f22..89a9fb36 100644 --- a/backend/src/db/migrations/2_FOLLOWING_V1.sql +++ b/backend/src/db/migrations/2_FOLLOWING_V1.sql @@ -19,4 +19,6 @@ INSERT INTO followings (follower_user_id, following_user_id) VALUES ('user_2chL8dX6HdbBAuvu3DDM9f9NzKK', 'user_2cpFbBLPGkPbszijtQneek7ZJxg'), ('user_2chL8dX6HdbBAuvu3DDM9f9NzKK', 'user_2dv5XFsCMYc4qLcsAnEJ1aUbxnk'), - ('user_2cpFbBLPGkPbszijtQneek7ZJxg', 'user_2chL8dX6HdbBAuvu3DDM9f9NzKK'); \ No newline at end of file + ('user_2cpFbBLPGkPbszijtQneek7ZJxg', 'user_2chL8dX6HdbBAuvu3DDM9f9NzKK'), + ('user_2fFVsSf4viW9pjx6Sd5dxEgutJ1', 'user_2dv5XFsCMYc4qLcsAnEJ1aUbxnk'), + ('user_2dv5XFsCMYc4qLcsAnEJ1aUbxnk', 'user_2fFVsSf4viW9pjx6Sd5dxEgutJ1'); \ No newline at end of file diff --git a/backend/src/db/migrations/5_USER_PORTFOLIO_V1.sql b/backend/src/db/migrations/5_USER_PORTFOLIO_V1.sql index bc9f02af..95f023b7 100644 --- a/backend/src/db/migrations/5_USER_PORTFOLIO_V1.sql +++ b/backend/src/db/migrations/5_USER_PORTFOLIO_V1.sql @@ -19,4 +19,4 @@ VALUES ('user_2chL8dX6HdbBAuvu3DDM9f9NzKK', 130, 14, 680, 93), ('user_2cpFbBLPGkPbszijtQneek7ZJxg', -14, -8, 680, 93), ('user_2dv5XFsCMYc4qLcsAnEJ1aUbxnk', 400, 3, 680, 93), -('user_2cwGfu9zcjsbxq5Lp8gy2rkVNlc', 200, 9, 680, 93); \ No newline at end of file +('user_2fFVsSf4viW9pjx6Sd5dxEgutJ1', 200, 9, 680, 93); \ No newline at end of file diff --git a/backend/src/models/user.go b/backend/src/models/user.go index a2b88592..6ade473d 100644 --- a/backend/src/models/user.go +++ b/backend/src/models/user.go @@ -3,7 +3,7 @@ package models import "time" type User struct { - ID string `gorm:"column:id;primaryKey;"` + ID string `gorm:"column:id;primaryKey;" json:"id"` CreatedAt time.Time `json:"created_at" example:"2023-09-20T16:34:50Z"` UpdatedAt time.Time `json:"updated_at" example:"2023-09-20T16:34:50Z"` FirstName string `gorm:"type:varchar(255)" json:"first_name"` diff --git a/backend/src/services/etrade.go b/backend/src/services/etrade.go index a0627657..5d2f2d94 100644 --- a/backend/src/services/etrade.go +++ b/backend/src/services/etrade.go @@ -4,6 +4,7 @@ import ( "backend/src/models" "backend/src/types" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -326,6 +327,9 @@ func getETradePortfolio(client *http.Client, tokens *models.OAuthTokens, account func (s *ETradeService) GetUserPortfolio(userID string) (models.UserPortfolio, error) { var portfolio models.UserPortfolio if err := s.DB.Preload("Positions").Where("user_id = ?", userID).First(&portfolio).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return models.UserPortfolio{}, nil + } return models.UserPortfolio{}, fmt.Errorf("error retrieving all positions from the database: %s", err) } diff --git a/frontend/components/ProfileBanner.tsx b/frontend/components/ProfileBanner.tsx index 3d1b2c47..7261d851 100644 --- a/frontend/components/ProfileBanner.tsx +++ b/frontend/components/ProfileBanner.tsx @@ -1,86 +1,115 @@ -import { StyleSheet, Text, View, Image, TouchableOpacity } from 'react-native' +import { + StyleSheet, + Text, + View, + Image, + TouchableOpacity, + Pressable, +} from 'react-native'; // import { theme } from '../../theme' -import React from 'react' +import React, { useEffect, useState } from 'react'; // import ActionButton from '../ActionButton' // import { useNavigation } from '@react-navigation/native' -import ProfileBio from './ProfileBio' -// import { useSession } from '@clerk/clerk-expo' +import ProfileBio from './ProfileBio'; +import { useNavigation } from '@react-navigation/native'; +import { AuthNavigationProp } from '../types/navigationTypes'; +import { getUserFollowers, getUserFollowing } from '../services/followers'; +import { User } from '../types/types'; +import { useSession } from '@clerk/clerk-expo'; interface ProfileBannerProps { - user?: string + user: User; } const ProfileBanner = ({ user }: ProfileBannerProps) => { - /* - const { session } = useSession() - const navigation = useNavigation() + const { session } = useSession(); + const navigation = useNavigation(); + const [following, setFollowing] = useState([]); + const [followers, setFollowers] = useState([]); - const navigateToEditProfile = () => { - navigation.navigate({ name: "Edit My Profile" }) - } - */ + const navigateToFollowers = () => { + navigation.navigate('Followers', { label: 'Followers', users: followers }); + }; - return ( - + const navigateToFollowing = () => { + navigation.navigate('Followers', { label: 'Followers', users: following }); + }; + + useEffect(() => { + getUserFollowers(user.id).then(users => { + setFollowers(users); + }); + getUserFollowing(user.id).then(users => { + setFollowing(users); + }); + }, []); - + console.log(user); + + return ( + + - - - - - 10 - - Followers + + + + + {followers?.length | 0} - + Followers + - - {10} - + + + {following?.length | 0} + + Following - + console.log(user)} - > - Edit Profile + className="flex items-center justify-center flex-1 mb-5 w-48" + style={profileStyles.followButton}> + Edit Profile - - ) -} - -export default ProfileBanner + ); +}; +export default ProfileBanner; const profileStyles = StyleSheet.create({ profileImage: { borderRadius: 180, - borderColor: "black", - borderWidth: 2, + borderColor: '#CCCCCC', + borderWidth: 1, }, followButton: { - backgroundColor: "rgba(2, 173, 152, 0.18)", + backgroundColor: 'rgba(2, 173, 152, 0.18)', paddingVertical: 5, paddingHorizontal: 20, - borderRadius: 5 + borderRadius: 5, }, -}) \ No newline at end of file +}); diff --git a/frontend/components/UserItem.tsx b/frontend/components/UserItem.tsx new file mode 100644 index 00000000..f3251077 --- /dev/null +++ b/frontend/components/UserItem.tsx @@ -0,0 +1,25 @@ +import { View, Text } from 'react-native' +import React from 'react' +import { Icon } from '@rneui/themed'; +import { User } from '../types/types'; + +interface UserItemProps { + user: User; +} + +const UserItem = ({ user }: UserItemProps) => { + return ( + + + + + {user.username} + + + + ) +} + +export default UserItem \ No newline at end of file diff --git a/frontend/pages/AuthPage.tsx b/frontend/pages/AuthPage.tsx index a348636e..7d933979 100644 --- a/frontend/pages/AuthPage.tsx +++ b/frontend/pages/AuthPage.tsx @@ -3,7 +3,7 @@ import React, {useEffect, useState} from 'react' import {RefreshControl, ScrollView} from "nativewind/dist/preflight"; import ETradeAuth from "../components/ETradeAuth"; import {TokenStatus} from "../types/types"; -import {getTokenStatus} from "../services/users"; +import {getTokenStatus} from "../services/etrade"; const AuthPage = () => { const [authenticated, setAuthenticated] = useState(false) @@ -15,7 +15,7 @@ const AuthPage = () => { } const getETradeTokenStatus = async () => { - const callback: TokenStatus = await getTokenStatus(2); + const callback: TokenStatus = await getTokenStatus('user_2chL8dX6HdbBAuvu3DDM9f9NzKK'); if (callback.status === "active") { setAuthenticated(true) } else { diff --git a/frontend/pages/Followers.tsx b/frontend/pages/Followers.tsx new file mode 100644 index 00000000..bd839406 --- /dev/null +++ b/frontend/pages/Followers.tsx @@ -0,0 +1,44 @@ +import { FlatList, Pressable } from 'react-native'; +import React, { useEffect } from 'react'; +import { Icon } from '@rneui/themed'; +import { useNavigation, useRoute } from '@react-navigation/native'; +import { FollowerRouteParams, User } from '../types/types'; +import UserItem from '../components/UserItem'; +import { AuthNavigationProp } from '../types/navigationTypes'; + +const Followers = () => { + const navigation = useNavigation(); + const route = useRoute(); + const users = (route.params as FollowerRouteParams)?.users; + + useEffect(() => { + // set the title of the page + navigation.setOptions({ + headerShown: true, + headerTitle: 'Followers', + headerTitleAlign: 'center', + headerLeft: () => ( + navigation.goBack()}> + + + ), + }); + }, []); + + const navigateToProfile = (user: User) => { + navigation.navigate('FollowerProfile', { user: user }) + }; + + return ( + item.id} + renderItem={({ item }) => ( + navigateToProfile(item)}> + + + )} /> + ); +}; + +export default Followers; \ No newline at end of file diff --git a/frontend/pages/Profile.tsx b/frontend/pages/Profile.tsx index 145eda15..f92bfdfb 100644 --- a/frontend/pages/Profile.tsx +++ b/frontend/pages/Profile.tsx @@ -1,4 +1,4 @@ -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useRoute } from '@react-navigation/native'; import React, { useEffect, useState } from 'react'; import { FlatList, Pressable, ScrollView, View } from 'react-native'; import { useSession } from '@clerk/clerk-expo'; @@ -10,37 +10,34 @@ import { ProfileActivityData } from '../constants'; import ProfilePerformance from '../components/ProfilePerformance'; import SignOut from '../components/SignOutButton'; import { getPortoflio } from '../services/etrade'; -import { UserPortfolio } from '../types/types'; +import { ProfileRouteParams, UserPortfolio } from '../types/types'; import { ProfilePositions } from '../components/ProfilePositions'; // import SettingsSvg from '../assets/SettingsIcon.svg'; + + const Profile = () => { const { session } = useSession(); const navigation = useNavigation(); - const [isPortfolioSelected, setIsPortfolioSelected] = useState(true); - const [isActivitySelected, setIsActivitySelected] = useState(false); const [pageNumber, setPageNumber] = useState(0); const [portfolio, setPortfolio] = useState() + const route = useRoute() + const isFollowerProfile = (route.params as ProfileRouteParams)?.user !== undefined; + const user = (route.params as ProfileRouteParams)?.user || session!.user!; const OnActivitySelected = () => { - setIsPortfolioSelected(false); - setIsActivitySelected(true); setPageNumber(1) - console.log(`Activity selected: ${isActivitySelected}`); } const OnPortfolioSelected = () => { - setIsPortfolioSelected(true); - setIsActivitySelected(false); setPageNumber(0) - console.log(`Portfolio selected: ${isPortfolioSelected}`); } useEffect(() => { // set the title of the page navigation.setOptions({ headerShown: true, - headerTitle: `@${session?.user.username}`, + headerTitle: `@${user.username}`, headerTitleAlign: 'center', headerRight: () => ( @@ -53,7 +50,6 @@ const Profile = () => { }) return navigation.addListener('focus', () => { - console.log(`Profile Page | session token: ${session?.getToken()}`); if (session?.user.username === undefined) { /* Unsure why casting to never is required, issue to look into */ navigation.navigate('Signin' as never); @@ -62,7 +58,7 @@ const Profile = () => { }, []); useEffect(() => { - getPortoflio("user_2ceWSEk1tU7bByPHmtwsla94w7e").then(userPortfolio => { + getPortoflio(user.id).then(userPortfolio => { setPortfolio(userPortfolio) }) }, []); @@ -70,11 +66,13 @@ const Profile = () => { return ( - + - + {!isFollowerProfile && ( + + )} (); @@ -35,7 +35,6 @@ export type BottomTabParamList = { }; const BottomNavBar = () => { - // const { session } = useSession(); return ( ({ @@ -63,7 +62,7 @@ const BottomNavBar = () => { /> { + return ( + + {} + + + + + ); +}; + +export default ProfileNavigator; diff --git a/frontend/services/followers.ts b/frontend/services/followers.ts new file mode 100644 index 00000000..bf2cf494 --- /dev/null +++ b/frontend/services/followers.ts @@ -0,0 +1,17 @@ +import { User } from '../types/types'; +import axios, { AxiosResponse } from 'axios'; +import { API_LINK } from './CommonDocs'; + +export const getUserFollowers = async (id: string): Promise => { + const response: AxiosResponse = await axios.get( + `http://${API_LINK}/followers/${id}`, + ); + return response.data; +}; + +export const getUserFollowing = async (id: string): Promise => { + const response: AxiosResponse = await axios.get( + `http://${API_LINK}/timelines/${id}`, + ); + return response.data; +}; \ No newline at end of file diff --git a/frontend/services/users.ts b/frontend/services/users.ts index 72d4745c..9d3ae71e 100644 --- a/frontend/services/users.ts +++ b/frontend/services/users.ts @@ -7,7 +7,6 @@ export const getAllUsers = async (): Promise => { const response: AxiosResponse = await axios.get( `http://${API_LINK}/users`, ); - // console.log(response.data); return response.data; }; @@ -39,6 +38,5 @@ export const registerUser = async ( LongTermGoals: longTermGoals, }, ); - // console.log(JSON.stringify(response)); return response.data; }; diff --git a/frontend/types/navigationTypes.ts b/frontend/types/navigationTypes.ts index 60acf660..07afc7e1 100644 --- a/frontend/types/navigationTypes.ts +++ b/frontend/types/navigationTypes.ts @@ -1,5 +1,6 @@ import { RouteProp } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; +import { User } from './types'; // import { createStackNavigator } from '@react-navigation/stack'; export type RootStackParamList = { @@ -15,6 +16,10 @@ export type RootStackParamList = { Confirmation: undefined; LongTermGoals: undefined; ShortTermGoals: undefined; + Profile: undefined; + ProfilePage: undefined; + FollowerProfile: { user: User } + Followers: { label: string, users: User[] }; // AuthPage: undefined; // TutorialPage: undefined; diff --git a/frontend/types/types.d.ts b/frontend/types/types.d.ts index a439db07..a707f1e1 100644 --- a/frontend/types/types.d.ts +++ b/frontend/types/types.d.ts @@ -1,4 +1,85 @@ export interface User { + id: string; + first_name: string; + last_name: string; + username: string; + pass_word: string; + email: string; + risk_tolerance: string; + years_of_experience: number; + image_url: string; +} + +export type FinancialGoal = { + goal: string; +}; + +export type Metadata = number | string | string[]; + +export interface Redirect { + redirect_url: string; +} + +export type Leader = { + leader_user: User; + follower_count: number; +} + +export type Trending = { + trending_user_reference: User; + day_gain_pct: number; +} + +export interface TokenStatus { + status: string; +} + +export type ClerkErrorResponse = { + status: number; + clerkError: string; + errors: ClerkError[]; +}; + +export type ClerkError = { + code: string; + message: string; + longMessage: string; +}; + +interface UserPortfolio { + user_id: string; + day_gain: number; + day_gain_pct: number; + total_gain: number; + total_gain_pct: number; + positions: Position[]; +} + +interface Position { + portfolio_id: number; + position_id: number; + ticker: string; + quantity: number; + cost: number; + day_gain: number; + day_gain_pct: number; + total_gain: number; + total_gain_pct: number; + type: TradeType; +} +enum TradeType { + LONG = 'LONG', + SHORT = 'SHORT' +} + +export interface ProfileRouteParams { + user: User; +} + +export interface FollowerRouteParams { + users: User[], + label: string +}export interface User { id: string, first_name: string; last_name: string;