From f2e8e092d493d1e4f06802724cf4cecba4ba7cc4 Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Sun, 16 Mar 2025 16:48:05 -0400 Subject: [PATCH 01/26] friends fav worksgit add .! --- backend/cmd/server/main.go | 1 + .../internal/handlers/menu_items/routes.go | 2 +- .../internal/handlers/menu_items/service.go | 2 +- .../handlers/restaurant/restaurant.go | 52 ++++++++ .../internal/handlers/restaurant/routes.go | 10 +- .../internal/handlers/restaurant/service.go | 114 +++++++++++++++++- backend/internal/handlers/restaurant/types.go | 8 +- backend/internal/handlers/review/routes.go | 2 +- backend/internal/handlers/review/service.go | 4 +- backend/internal/handlers/review/types.go | 2 +- .../handlers/user_connections/routes.go | 2 +- .../handlers/user_connections/service.go | 19 ++- backend/internal/storage/mongo/validations.go | 4 +- 13 files changed, 203 insertions(+), 19 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a80cbf92..b77eacf3 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -68,6 +68,7 @@ func run(stderr io.Writer, args []string) { fatal(ctx, "Failed to connect to MongoDB", err) } + app := server.New(db.Collections) go func() { diff --git a/backend/internal/handlers/menu_items/routes.go b/backend/internal/handlers/menu_items/routes.go index 6a16fc08..599a3b3a 100644 --- a/backend/internal/handlers/menu_items/routes.go +++ b/backend/internal/handlers/menu_items/routes.go @@ -9,7 +9,7 @@ import ( Router maps endpoints to handlers */ func Routes(app *fiber.App, collections map[string]*mongo.Collection) { - service := newService(collections) + service := NewService(collections) handler := Handler{service} menuGroup := app.Group("/api/v1/menu-items") diff --git a/backend/internal/handlers/menu_items/service.go b/backend/internal/handlers/menu_items/service.go index c89bb907..c8a6ac21 100644 --- a/backend/internal/handlers/menu_items/service.go +++ b/backend/internal/handlers/menu_items/service.go @@ -14,7 +14,7 @@ import ( "go.mongodb.org/mongo-driver/mongo/options" ) -func newService(collections map[string]*mongo.Collection) *Service { +func NewService(collections map[string]*mongo.Collection) *Service { if collections["menuItems"] == nil { slog.Info("menuItems collection is nil!") } diff --git a/backend/internal/handlers/restaurant/restaurant.go b/backend/internal/handlers/restaurant/restaurant.go index 404e257c..219e90a5 100644 --- a/backend/internal/handlers/restaurant/restaurant.go +++ b/backend/internal/handlers/restaurant/restaurant.go @@ -2,6 +2,7 @@ package restaurant import ( "errors" + "fmt" "github.com/GenerateNU/platemate/internal/xerr" go_json "github.com/goccy/go-json" @@ -117,3 +118,54 @@ func (h *Handler) DeleteRestaurant(c *fiber.Ctx) error { } return c.SendStatus(fiber.StatusNoContent) } + +// GetRestaurantFriendsFav by ID +func (h *Handler) GetRestaurantFriendsFav(c *fiber.Ctx) error { + restaurantIdParam := c.Params("rid") + fmt.Println(restaurantIdParam) + userIdParam := c.Params("uid") + fmt.Println(userIdParam) + + restaurantObjID, err := primitive.ObjectIDFromHex(restaurantIdParam) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(xerr.BadRequest(err)) + } + fmt.Println(restaurantObjID) + userObjID, err := primitive.ObjectIDFromHex(userIdParam) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(xerr.BadRequest(err)) + } + fmt.Println(userObjID) + friendsFav, err := h.service.GetRestaurantFriendsFav(userObjID, restaurantObjID) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return c.Status(fiber.StatusNotFound). + JSON(xerr.NotFound("Restaurant", "id", restaurantIdParam)) + } + return xerr.ErrorHandler(c, err) + } + + return c.JSON(friendsFav) +} + +// get SuperStars by restaurant ID +func (h *Handler) GetSuperStars(c *fiber.Ctx) error { + restaurantIdParam := c.Params("rid") + fmt.Println(restaurantIdParam) + + restaurantObjID, err := primitive.ObjectIDFromHex(restaurantIdParam) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(xerr.BadRequest(err)) + } + fmt.Println(restaurantObjID) + superStars, err := h.service.GetSuperStars(restaurantObjID) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return c.Status(fiber.StatusNotFound). + JSON(xerr.NotFound("Restaurant", "id", restaurantIdParam)) + } + return xerr.ErrorHandler(c, err) + } + + return c.JSON(superStars) +} diff --git a/backend/internal/handlers/restaurant/routes.go b/backend/internal/handlers/restaurant/routes.go index 7a144e23..c5352656 100644 --- a/backend/internal/handlers/restaurant/routes.go +++ b/backend/internal/handlers/restaurant/routes.go @@ -1,12 +1,18 @@ package restaurant import ( + "github.com/GenerateNU/platemate/internal/handlers/menu_items" + "github.com/GenerateNU/platemate/internal/handlers/user_connections" + "github.com/GenerateNU/platemate/internal/handlers/review" "github.com/gofiber/fiber/v2" "go.mongodb.org/mongo-driver/mongo" ) func Routes(app *fiber.App, collections map[string]*mongo.Collection) { - service := newService(collections) + userConnectionsService := user_connections.NewService(collections) + menuItemsService := menu_items.NewService(collections) + reviewService := review.NewService(collections) + service := newService(collections, userConnectionsService, menuItemsService, reviewService) handler := Handler{service: service} apiV1 := app.Group("/api/v1") @@ -18,4 +24,6 @@ func Routes(app *fiber.App, collections map[string]*mongo.Collection) { rest.Put("/:id", handler.UpdateRestaurant) // PUT /api/v1/restaurant/:id (full update) rest.Patch("/:id", handler.UpdatePartialRestaurant) // PATCH /api/v1/restaurant/:id (partial update) rest.Delete("/:id", handler.DeleteRestaurant) + rest.Get("/:uid/:rid", handler.GetRestaurantFriendsFav) + rest.Get("/:rid", handler.GetSuperStars) } diff --git a/backend/internal/handlers/restaurant/service.go b/backend/internal/handlers/restaurant/service.go index 65a4ed77..7ae53ff7 100644 --- a/backend/internal/handlers/restaurant/service.go +++ b/backend/internal/handlers/restaurant/service.go @@ -2,19 +2,29 @@ package restaurant import ( "context" + "fmt" + "github.com/GenerateNU/platemate/internal/handlers/menu_items" + "github.com/GenerateNU/platemate/internal/handlers/review" + "github.com/GenerateNU/platemate/internal/handlers/user_connections" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" ) type Service struct { - restaurants *mongo.Collection + restaurants *mongo.Collection + userConnectionsService *user_connections.Service + menuItemsService *menu_items.Service + reviewService *review.Service } -func newService(collections map[string]*mongo.Collection) *Service { +func newService(collections map[string]*mongo.Collection, userConnectionsService *user_connections.Service, menuItemsService *menu_items.Service, reviewService *review.Service) *Service { return &Service{ - restaurants: collections["restaurants"], + restaurants: collections["restaurants"], + userConnectionsService: userConnectionsService, + menuItemsService: menuItemsService, + reviewService: reviewService, } } @@ -157,3 +167,101 @@ func (s *Service) DeleteRestaurant(id primitive.ObjectID) error { } return nil } + +// GetRestaurantFriendsFav +// average of all your friends reviews for all menu items at restaurant --> if above 4 its a friend fav +// if its a friend fav -- display all of the friends that have been to the restaurant +func (s *Service) GetRestaurantFriendsFav(uid primitive.ObjectID, rid primitive.ObjectID) (*FriendsFav, error) { + ctx := context.Background() + + var restaurant RestaurantDocument + var friends []string + var totalRating int + var numOfRatings int + var avgRating float64 + var friendsFav *FriendsFav + // restaurant is stored in doc + err := s.restaurants.FindOne(ctx, bson.M{"_id": rid}).Decode(&restaurant) + if err == mongo.ErrNoDocuments { + // No matching restaurant found + return nil, mongo.ErrNoDocuments + } else if err != nil { + // Different error occurred + return nil, err + } + for _, menuItemId := range restaurant.MenuItems { + // why are the inputs for this method strings when all id's are represented as ObjectID's ??? + fmt.Println(menuItemId) + friendReviews, err := s.userConnectionsService.GetFriendReviewsForItem(uid.Hex(), menuItemId.Hex()) + fmt.Println(err) + fmt.Println("friend reviews for menuItem", len(friendReviews)) + for _, friendReview := range friendReviews { + // storing the friends of the user that reviewed menuItems at the restaurant + fmt.Println(friendReview.Reviewer) + friend := friendReview.Reviewer.ID + fmt.Println("friend id:", friend) + if doesNotContain(friends, friend) { + friends = append(friends, friend) + } + totalRating += friendReview.Rating.Overall + numOfRatings += 1 + } + } + avgRating = float64(totalRating) / float64(numOfRatings) + // the restaurant is a friends fav + if avgRating >= 4 { + friendsFav = &FriendsFav{ + IsFriendsFav: true, + FriendsReviewed: len(friends), + } + } else { + friendsFav = &FriendsFav{ + IsFriendsFav: false, + FriendsReviewed: len(friends), + } + } + + return friendsFav, nil +} + +// checks if an element is not in a slice +func doesNotContain(slice []string, item string) bool { + for _, element := range slice { + if element == item { + return false + } + } + return true +} + +// super stars: go through all of the reviews for all the menu items at a restaurant and count how many are 5 star reviews +func (s *Service) GetSuperStars(rid primitive.ObjectID) (int, error) { + ctx := context.Background() + + var restaurant RestaurantDocument + var superStars int + + // restaurant is stored in doc + err := s.restaurants.FindOne(ctx, bson.M{"_id": rid}).Decode(&restaurant) + if err == mongo.ErrNoDocuments { + // No matching restaurant found + return 0, mongo.ErrNoDocuments + } else if err != nil { + // Different error occurred + return 0, err + } + for _, menuItemId := range restaurant.MenuItems { + menuItem, _ := s.menuItemsService.GetMenuItemById(menuItemId) + reviews := menuItem.Reviews + for _, reviewId := range reviews { + reviewIdObj, _ := primitive.ObjectIDFromHex(reviewId) + review, _ := s.reviewService.GetReviewByID(reviewIdObj) + rating := review.Rating.Overall + if rating == 5 { + superStars += 1 + } + } + } + + return superStars, nil +} diff --git a/backend/internal/handlers/restaurant/types.go b/backend/internal/handlers/restaurant/types.go index 1b722090..e72ead73 100644 --- a/backend/internal/handlers/restaurant/types.go +++ b/backend/internal/handlers/restaurant/types.go @@ -12,7 +12,7 @@ type RestaurantDocument struct { Street string `bson:"street" json:"street"` Zipcode string `bson:"zipcode" json:"zipcode"` State string `bson:"state" json:"state"` - Location []int `bson:"location" json:"location"` + Location []float64 `bson:"location" json:"location"` } `bson:"address" json:"address"` MenuItems []primitive.ObjectID `bson:"menuItems" json:"menuItems"` @@ -27,3 +27,9 @@ type RestaurantDocument struct { Description string `bson:"description" json:"description"` Tags []string `bson:"tags" json:"tags"` } + +type FriendsFav struct { + IsFriendsFav bool `json:"friends_fav"` + FriendsReviewed int `json:"friends_reviewed"` +} + diff --git a/backend/internal/handlers/review/routes.go b/backend/internal/handlers/review/routes.go index 25821dfa..3a699bde 100644 --- a/backend/internal/handlers/review/routes.go +++ b/backend/internal/handlers/review/routes.go @@ -9,7 +9,7 @@ import ( Router maps endpoints to handlers */ func Routes(app *fiber.App, collections map[string]*mongo.Collection) { - service := newService(collections) + service := NewService(collections) handler := Handler{service} // Add a group for API versioning diff --git a/backend/internal/handlers/review/service.go b/backend/internal/handlers/review/service.go index 3cb00111..f81a2288 100644 --- a/backend/internal/handlers/review/service.go +++ b/backend/internal/handlers/review/service.go @@ -10,8 +10,8 @@ import ( "go.mongodb.org/mongo-driver/mongo" ) -// newService receives the map of collections and picks out reviews -func newService(collections map[string]*mongo.Collection) *Service { +// NewService receives the map of collections and picks out reviews +func NewService(collections map[string]*mongo.Collection) *Service { return &Service{ reviews: collections["reviews"], restaurants: collections["restaurants"], diff --git a/backend/internal/handlers/review/types.go b/backend/internal/handlers/review/types.go index e11f98a1..a5c3f2fb 100644 --- a/backend/internal/handlers/review/types.go +++ b/backend/internal/handlers/review/types.go @@ -39,7 +39,7 @@ type Rating struct { // Reviewer is a nested struct in ReviewDocument. type Reviewer struct { - ID string `bson:"id" json:"id"` + ID string `bson:"_id" json:"_id"` PFP string `bson:"pfp" json:"pfp"` Username string `bson:"username" json:"username"` } diff --git a/backend/internal/handlers/user_connections/routes.go b/backend/internal/handlers/user_connections/routes.go index 72c6d239..d432fbda 100644 --- a/backend/internal/handlers/user_connections/routes.go +++ b/backend/internal/handlers/user_connections/routes.go @@ -6,7 +6,7 @@ import ( ) func Routes(app *fiber.App, collections map[string]*mongo.Collection) { - service := newService(collections) + service := NewService(collections) handler := Handler{service} // Group under API Version 1 diff --git a/backend/internal/handlers/user_connections/service.go b/backend/internal/handlers/user_connections/service.go index 6741ce85..f4adcc58 100644 --- a/backend/internal/handlers/user_connections/service.go +++ b/backend/internal/handlers/user_connections/service.go @@ -3,6 +3,7 @@ package user_connections import ( "context" "errors" + "fmt" "github.com/GenerateNU/platemate/internal/handlers/menu_items" "github.com/GenerateNU/platemate/internal/handlers/review" @@ -24,7 +25,7 @@ type Service struct { menuItems *mongo.Collection } -func newService(collections map[string]*mongo.Collection) *Service { +func NewService(collections map[string]*mongo.Collection) *Service { return &Service{ users: collections["users"], reviews: collections["reviews"], @@ -363,7 +364,16 @@ func (s *Service) GetFriendReviewsForItem(userId string, menuItemId string) ([]r if len(user.Following) == 0 || len(user.Followers) == 0 { // No friends :( return []review.ReviewDocument{}, nil } + // ADDED to account for type mismatch + var followingIDs []string + for _, id := range user.Following { + followingIDs = append(followingIDs, id.Hex()) // Convert ObjectID to string + } + var followerIDs []string + for _, id := range user.Followers { + followerIDs = append(followerIDs, id.Hex()) // Convert ObjectID to string + } // Query reviews that match both menu item reviews and friends (follower and following) reviewsCursor, err := s.reviews.Find(ctx, bson.M{ @@ -372,10 +382,10 @@ func (s *Service) GetFriendReviewsForItem(userId string, menuItemId string) ([]r "_id": bson.M{"$in": menuItem.Reviews}, // Match reviews related to the menu item }, { - "reviewer._id": bson.M{"$in": user.Following}, // Reviewer's ID must be in the Following list + "reviewer._id": bson.M{"$in": followingIDs}, // Reviewer's ID must be in the Following list }, { - "reviewer._id": bson.M{"$in": user.Followers}, // Reviewer's ID must also be in the Followers list + "reviewer._id": bson.M{"$in": followerIDs}, // Reviewer's ID must be in the Followers list }, }, }, @@ -385,12 +395,11 @@ func (s *Service) GetFriendReviewsForItem(userId string, menuItemId string) ([]r return nil, err } defer reviewsCursor.Close(ctx) - var reviews []review.ReviewDocument if err = reviewsCursor.All(ctx, &reviews); err != nil { return nil, err } - + fmt.Printf("reviews before sending: %+v\n", reviews) return reviews, nil } diff --git a/backend/internal/storage/mongo/validations.go b/backend/internal/storage/mongo/validations.go index 6a159ec4..1c95126b 100644 --- a/backend/internal/storage/mongo/validations.go +++ b/backend/internal/storage/mongo/validations.go @@ -61,7 +61,7 @@ var ( "location": bson.M{ "bsonType": "array", "items": bson.M{ - "bsonType": "double", + "bsonType": "float", }, "minItems": 2, "maxItems": 2, @@ -71,7 +71,7 @@ var ( "menuItems": bson.M{ "bsonType": "array", "items": bson.M{ - "bsonType": "objectId", + "bsonType": "int", }, }, "ratingAvg": bson.M{ From 6aba904fa3331b439ab21237eaa323bc9ff72e4a Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Sun, 16 Mar 2025 17:30:25 -0400 Subject: [PATCH 02/26] super stars also worksgit add .! --- backend/internal/handlers/restaurant/restaurant.go | 5 +++-- backend/internal/handlers/restaurant/routes.go | 4 ++-- backend/internal/handlers/restaurant/service.go | 1 + backend/internal/handlers/user_connections/service.go | 3 --- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/backend/internal/handlers/restaurant/restaurant.go b/backend/internal/handlers/restaurant/restaurant.go index 219e90a5..73a559f2 100644 --- a/backend/internal/handlers/restaurant/restaurant.go +++ b/backend/internal/handlers/restaurant/restaurant.go @@ -150,14 +150,15 @@ func (h *Handler) GetRestaurantFriendsFav(c *fiber.Ctx) error { // get SuperStars by restaurant ID func (h *Handler) GetSuperStars(c *fiber.Ctx) error { + fmt.Println("helloooo") restaurantIdParam := c.Params("rid") - fmt.Println(restaurantIdParam) + fmt.Println("param", restaurantIdParam) restaurantObjID, err := primitive.ObjectIDFromHex(restaurantIdParam) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(xerr.BadRequest(err)) } - fmt.Println(restaurantObjID) + fmt.Println("object form", restaurantObjID) superStars, err := h.service.GetSuperStars(restaurantObjID) if err != nil { if errors.Is(err, mongo.ErrNoDocuments) { diff --git a/backend/internal/handlers/restaurant/routes.go b/backend/internal/handlers/restaurant/routes.go index c5352656..36a4b1f5 100644 --- a/backend/internal/handlers/restaurant/routes.go +++ b/backend/internal/handlers/restaurant/routes.go @@ -2,8 +2,8 @@ package restaurant import ( "github.com/GenerateNU/platemate/internal/handlers/menu_items" - "github.com/GenerateNU/platemate/internal/handlers/user_connections" "github.com/GenerateNU/platemate/internal/handlers/review" + "github.com/GenerateNU/platemate/internal/handlers/user_connections" "github.com/gofiber/fiber/v2" "go.mongodb.org/mongo-driver/mongo" ) @@ -24,6 +24,6 @@ func Routes(app *fiber.App, collections map[string]*mongo.Collection) { rest.Put("/:id", handler.UpdateRestaurant) // PUT /api/v1/restaurant/:id (full update) rest.Patch("/:id", handler.UpdatePartialRestaurant) // PATCH /api/v1/restaurant/:id (partial update) rest.Delete("/:id", handler.DeleteRestaurant) + rest.Get("/:rid/super-stars", handler.GetSuperStars) rest.Get("/:uid/:rid", handler.GetRestaurantFriendsFav) - rest.Get("/:rid", handler.GetSuperStars) } diff --git a/backend/internal/handlers/restaurant/service.go b/backend/internal/handlers/restaurant/service.go index 7ae53ff7..f70b6811 100644 --- a/backend/internal/handlers/restaurant/service.go +++ b/backend/internal/handlers/restaurant/service.go @@ -256,6 +256,7 @@ func (s *Service) GetSuperStars(rid primitive.ObjectID) (int, error) { for _, reviewId := range reviews { reviewIdObj, _ := primitive.ObjectIDFromHex(reviewId) review, _ := s.reviewService.GetReviewByID(reviewIdObj) + // checks if the overall rating is 5 rating := review.Rating.Overall if rating == 5 { superStars += 1 diff --git a/backend/internal/handlers/user_connections/service.go b/backend/internal/handlers/user_connections/service.go index f4adcc58..d32642e2 100644 --- a/backend/internal/handlers/user_connections/service.go +++ b/backend/internal/handlers/user_connections/service.go @@ -3,8 +3,6 @@ package user_connections import ( "context" "errors" - "fmt" - "github.com/GenerateNU/platemate/internal/handlers/menu_items" "github.com/GenerateNU/platemate/internal/handlers/review" "github.com/GenerateNU/platemate/internal/xerr" @@ -399,7 +397,6 @@ func (s *Service) GetFriendReviewsForItem(userId string, menuItemId string) ([]r if err = reviewsCursor.All(ctx, &reviews); err != nil { return nil, err } - fmt.Printf("reviews before sending: %+v\n", reviews) return reviews, nil } From 4f062577bf5ec78608c7b86a8cfb431bb0480c0f Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Mon, 17 Mar 2025 01:13:19 -0400 Subject: [PATCH 03/26] sorting menu item reviews worksgit add .! --- .../handlers/menu_items/menu_items.go | 25 ++++++++++++++++--- .../internal/handlers/menu_items/service.go | 11 +++++--- backend/internal/handlers/menu_items/types.go | 1 + .../internal/handlers/restaurant/service.go | 2 +- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/backend/internal/handlers/menu_items/menu_items.go b/backend/internal/handlers/menu_items/menu_items.go index 9e937510..73b4203a 100644 --- a/backend/internal/handlers/menu_items/menu_items.go +++ b/backend/internal/handlers/menu_items/menu_items.go @@ -6,6 +6,7 @@ import ( "log/slog" "strings" + "github.com/GenerateNU/platemate/internal/handlers/review" "github.com/GenerateNU/platemate/internal/xerr" "github.com/gofiber/fiber/v2" "go.mongodb.org/mongo-driver/bson/primitive" @@ -42,6 +43,13 @@ var ValidDietaryRestrictions = map[string]bool{ "lactose-intolerant": true, } +var ValidSortingParams = map[string]bool{ + "portion": true, + "taste": true, + "value": true, + "overall": true, +} + func PreprocessMenuItemRequest(menuItem MenuItemRequest) MenuItemRequest { // Default nil arrays to empty if menuItem.Reviews == nil { @@ -344,9 +352,20 @@ func (h *Handler) GetMenuItemReviews(c *fiber.Ctx) error { userObjID = &parsedUserID } - reviews, err := h.service.GetMenuItemReviews(objID, userObjID) - if err != nil { - return err + var reviews []review.ReviewDocument + var err error + sortParam := query.SortBy + // checks if the menu item reviews should be sorted + if ValidSortingParams[sortParam] { + reviews, err = h.service.GetMenuItemReviews(objID, userObjID, sortParam) + if err != nil { + return err + } + } else { + reviews, err = h.service.GetMenuItemReviews(objID, userObjID, "timestamp") + if err != nil { + return err + } } return c.Status(fiber.StatusOK).JSON(reviews) } diff --git a/backend/internal/handlers/menu_items/service.go b/backend/internal/handlers/menu_items/service.go index c8a6ac21..3a65cd9c 100644 --- a/backend/internal/handlers/menu_items/service.go +++ b/backend/internal/handlers/menu_items/service.go @@ -279,7 +279,7 @@ func (s *Service) GetSimilarMenuItems(itemID primitive.ObjectID) ([]MenuItemResp return similarItems, nil } -func (s *Service) GetMenuItemReviews(idObj primitive.ObjectID, userID *primitive.ObjectID) ([]review.ReviewDocument, error) { +func (s *Service) GetMenuItemReviews(idObj primitive.ObjectID, userID *primitive.ObjectID, sortParam string) ([]review.ReviewDocument, error) { var menuItemDoc MenuItemDocument ctx := context.Background() err := s.menuItems.FindOne(ctx, bson.M{"_id": idObj}).Decode(&menuItemDoc) @@ -301,8 +301,13 @@ func (s *Service) GetMenuItemReviews(idObj primitive.ObjectID, userID *primitive } - // Query reviews that match menu item and user, if provided - reviewsCursor, err := s.reviews.Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "timestamp", Value: -1}})) + // Query reviews that match menu item and user and sorts if provided + + // edits the sorting paramater to properly query + if sortParam != "timestamp" { + sortParam = "rating." + sortParam + } + reviewsCursor, err := s.reviews.Find(ctx, filter, options.Find().SetSort(bson.D{{Key: sortParam, Value: -1}})) if err != nil { slog.Error("Error finding reviews", "error", err) return nil, err diff --git a/backend/internal/handlers/menu_items/types.go b/backend/internal/handlers/menu_items/types.go index d431ad09..5504f90f 100644 --- a/backend/internal/handlers/menu_items/types.go +++ b/backend/internal/handlers/menu_items/types.go @@ -46,6 +46,7 @@ type MenuItemsQuery struct { type MenuItemReviewQuery struct { UserID *string `query:"userID"` + SortBy string `query:"sortBy"` } /* diff --git a/backend/internal/handlers/restaurant/service.go b/backend/internal/handlers/restaurant/service.go index f70b6811..d970d76c 100644 --- a/backend/internal/handlers/restaurant/service.go +++ b/backend/internal/handlers/restaurant/service.go @@ -234,7 +234,6 @@ func doesNotContain(slice []string, item string) bool { return true } -// super stars: go through all of the reviews for all the menu items at a restaurant and count how many are 5 star reviews func (s *Service) GetSuperStars(rid primitive.ObjectID) (int, error) { ctx := context.Background() @@ -266,3 +265,4 @@ func (s *Service) GetSuperStars(rid primitive.ObjectID) (int, error) { return superStars, nil } + From 0601a4fc0ed3d5d908d806c85e67fccbb4661d05 Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Mon, 17 Mar 2025 01:31:31 -0400 Subject: [PATCH 04/26] added commens and removed print statements --- backend/internal/handlers/restaurant/restaurant.go | 12 +++--------- backend/internal/handlers/restaurant/service.go | 11 ++++------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/backend/internal/handlers/restaurant/restaurant.go b/backend/internal/handlers/restaurant/restaurant.go index 73a559f2..84efcc3f 100644 --- a/backend/internal/handlers/restaurant/restaurant.go +++ b/backend/internal/handlers/restaurant/restaurant.go @@ -2,8 +2,6 @@ package restaurant import ( "errors" - "fmt" - "github.com/GenerateNU/platemate/internal/xerr" go_json "github.com/goccy/go-json" "github.com/gofiber/fiber/v2" @@ -122,20 +120,18 @@ func (h *Handler) DeleteRestaurant(c *fiber.Ctx) error { // GetRestaurantFriendsFav by ID func (h *Handler) GetRestaurantFriendsFav(c *fiber.Ctx) error { restaurantIdParam := c.Params("rid") - fmt.Println(restaurantIdParam) userIdParam := c.Params("uid") - fmt.Println(userIdParam) restaurantObjID, err := primitive.ObjectIDFromHex(restaurantIdParam) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(xerr.BadRequest(err)) } - fmt.Println(restaurantObjID) + userObjID, err := primitive.ObjectIDFromHex(userIdParam) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(xerr.BadRequest(err)) } - fmt.Println(userObjID) + friendsFav, err := h.service.GetRestaurantFriendsFav(userObjID, restaurantObjID) if err != nil { if errors.Is(err, mongo.ErrNoDocuments) { @@ -150,15 +146,13 @@ func (h *Handler) GetRestaurantFriendsFav(c *fiber.Ctx) error { // get SuperStars by restaurant ID func (h *Handler) GetSuperStars(c *fiber.Ctx) error { - fmt.Println("helloooo") restaurantIdParam := c.Params("rid") - fmt.Println("param", restaurantIdParam) restaurantObjID, err := primitive.ObjectIDFromHex(restaurantIdParam) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(xerr.BadRequest(err)) } - fmt.Println("object form", restaurantObjID) + superStars, err := h.service.GetSuperStars(restaurantObjID) if err != nil { if errors.Is(err, mongo.ErrNoDocuments) { diff --git a/backend/internal/handlers/restaurant/service.go b/backend/internal/handlers/restaurant/service.go index d970d76c..20ba929d 100644 --- a/backend/internal/handlers/restaurant/service.go +++ b/backend/internal/handlers/restaurant/service.go @@ -2,7 +2,6 @@ package restaurant import ( "context" - "fmt" "github.com/GenerateNU/platemate/internal/handlers/menu_items" "github.com/GenerateNU/platemate/internal/handlers/review" @@ -191,15 +190,14 @@ func (s *Service) GetRestaurantFriendsFav(uid primitive.ObjectID, rid primitive. } for _, menuItemId := range restaurant.MenuItems { // why are the inputs for this method strings when all id's are represented as ObjectID's ??? - fmt.Println(menuItemId) friendReviews, err := s.userConnectionsService.GetFriendReviewsForItem(uid.Hex(), menuItemId.Hex()) - fmt.Println(err) - fmt.Println("friend reviews for menuItem", len(friendReviews)) + if err != nil { + return nil, err + } for _, friendReview := range friendReviews { // storing the friends of the user that reviewed menuItems at the restaurant - fmt.Println(friendReview.Reviewer) friend := friendReview.Reviewer.ID - fmt.Println("friend id:", friend) + // ensure that all the friends added to the list are unique if doesNotContain(friends, friend) { friends = append(friends, friend) } @@ -265,4 +263,3 @@ func (s *Service) GetSuperStars(rid primitive.ObjectID) (int, error) { return superStars, nil } - From 0d9ac1d18ca61c59a42c8b8be5907959a552a11e Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Mon, 17 Mar 2025 02:03:02 -0400 Subject: [PATCH 05/26] linted my code --- backend/cmd/server/main.go | 1 - backend/internal/handlers/menu_items/types.go | 2 +- backend/internal/handlers/restaurant/types.go | 13 ++++++------- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index b77eacf3..a80cbf92 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -68,7 +68,6 @@ func run(stderr io.Writer, args []string) { fatal(ctx, "Failed to connect to MongoDB", err) } - app := server.New(db.Collections) go func() { diff --git a/backend/internal/handlers/menu_items/types.go b/backend/internal/handlers/menu_items/types.go index 5504f90f..652c6643 100644 --- a/backend/internal/handlers/menu_items/types.go +++ b/backend/internal/handlers/menu_items/types.go @@ -46,7 +46,7 @@ type MenuItemsQuery struct { type MenuItemReviewQuery struct { UserID *string `query:"userID"` - SortBy string `query:"sortBy"` + SortBy string `query:"sortBy"` } /* diff --git a/backend/internal/handlers/restaurant/types.go b/backend/internal/handlers/restaurant/types.go index e72ead73..5b8f71b2 100644 --- a/backend/internal/handlers/restaurant/types.go +++ b/backend/internal/handlers/restaurant/types.go @@ -9,10 +9,10 @@ type RestaurantDocument struct { Name string `bson:"name" json:"name"` Address struct { - Street string `bson:"street" json:"street"` - Zipcode string `bson:"zipcode" json:"zipcode"` - State string `bson:"state" json:"state"` - Location []float64 `bson:"location" json:"location"` + Street string `bson:"street" json:"street"` + Zipcode string `bson:"zipcode" json:"zipcode"` + State string `bson:"state" json:"state"` + Location []float64 `bson:"location" json:"location"` } `bson:"address" json:"address"` MenuItems []primitive.ObjectID `bson:"menuItems" json:"menuItems"` @@ -29,7 +29,6 @@ type RestaurantDocument struct { } type FriendsFav struct { - IsFriendsFav bool `json:"friends_fav"` - FriendsReviewed int `json:"friends_reviewed"` + IsFriendsFav bool `json:"friends_fav"` + FriendsReviewed int `json:"friends_reviewed"` } - From 91fe048d0a9ef6e583d179acfc6a6fe1bccb5802 Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Tue, 18 Mar 2025 20:48:04 -0400 Subject: [PATCH 06/26] made some changes after pr reviewed --- .../handlers/menu_items/menu_items.go | 18 +++----- .../restaurant/playground-1.mongodb.js | 43 +++++++++++++++++++ .../restaurant/playground-5.mongodb.js | 6 +++ .../restaurant/playground-6.mongodb.js | 36 ++++++++++++++++ .../internal/handlers/restaurant/service.go | 21 +++------ 5 files changed, 97 insertions(+), 27 deletions(-) create mode 100644 backend/internal/handlers/restaurant/playground-1.mongodb.js create mode 100644 backend/internal/handlers/restaurant/playground-5.mongodb.js create mode 100644 backend/internal/handlers/restaurant/playground-6.mongodb.js diff --git a/backend/internal/handlers/menu_items/menu_items.go b/backend/internal/handlers/menu_items/menu_items.go index 73b4203a..827e0b03 100644 --- a/backend/internal/handlers/menu_items/menu_items.go +++ b/backend/internal/handlers/menu_items/menu_items.go @@ -354,18 +354,14 @@ func (h *Handler) GetMenuItemReviews(c *fiber.Ctx) error { var reviews []review.ReviewDocument var err error - sortParam := query.SortBy + sortParam := "timestamp" + if ValidSortingParams[query.SortBy] { + sortParam = query.SortBy + } // checks if the menu item reviews should be sorted - if ValidSortingParams[sortParam] { - reviews, err = h.service.GetMenuItemReviews(objID, userObjID, sortParam) - if err != nil { - return err - } - } else { - reviews, err = h.service.GetMenuItemReviews(objID, userObjID, "timestamp") - if err != nil { - return err - } + reviews, err = h.service.GetMenuItemReviews(objID, userObjID, sortParam) + if err != nil { + return err } return c.Status(fiber.StatusOK).JSON(reviews) } diff --git a/backend/internal/handlers/restaurant/playground-1.mongodb.js b/backend/internal/handlers/restaurant/playground-1.mongodb.js new file mode 100644 index 00000000..ea4e1846 --- /dev/null +++ b/backend/internal/handlers/restaurant/playground-1.mongodb.js @@ -0,0 +1,43 @@ +/* global use, db */ +// MongoDB Playground +// Use Ctrl+Space inside a snippet or a string literal to trigger completions. + +const database = 'NEW_DATABASE_NAME'; +const collection = 'NEW_COLLECTION_NAME'; + +// Create a new database. +use(database); + +// Create a new collection. +db.createCollection(collection); + +// The prototype form to create a collection: +/* db.createCollection( , + { + capped: , + autoIndexId: , + size: , + max: , + storageEngine: , + validator: , + validationLevel: , + validationAction: , + indexOptionDefaults: , + viewOn: , + pipeline: , + collation: , + writeConcern: , + timeseries: { // Added in MongoDB 5.0 + timeField: , // required for time series collections + metaField: , + granularity: , + bucketMaxSpanSeconds: , // Added in MongoDB 6.3 + bucketRoundingSeconds: , // Added in MongoDB 6.3 + }, + expireAfterSeconds: , + clusteredIndex: , // Added in MongoDB 5.3 + } +)*/ + +// More information on the `createCollection` command can be found at: +// https://www.mongodb.com/docs/manual/reference/method/db.createCollection/ diff --git a/backend/internal/handlers/restaurant/playground-5.mongodb.js b/backend/internal/handlers/restaurant/playground-5.mongodb.js new file mode 100644 index 00000000..279dfdd3 --- /dev/null +++ b/backend/internal/handlers/restaurant/playground-5.mongodb.js @@ -0,0 +1,6 @@ +// MongoDB Playground +// Use Ctrl+Space inside a snippet or a string literal to trigger completions. + +// The current database to use. +use("dev-pop-with-friends"); + diff --git a/backend/internal/handlers/restaurant/playground-6.mongodb.js b/backend/internal/handlers/restaurant/playground-6.mongodb.js new file mode 100644 index 00000000..ab982fc2 --- /dev/null +++ b/backend/internal/handlers/restaurant/playground-6.mongodb.js @@ -0,0 +1,36 @@ +// MongoDB Playground +// Use Ctrl+Space inside a snippet or a string literal to trigger completions. + +// The current database to use. +use('dev-pop-with-friends'); + +// Create a new document in the collection. +db.getCollection('reviews').insertOne({ + { + "_id": { + "$oid": "000000000000000000000008" + }, + "rating": { + "portion": 5, + "taste": 5, + "value": 4, + "overall": 5, + "return": true + }, + "picture": "", + "content": "Best cheeseburger ever!", + "reviewer": { + "id": "000000000000000000000002", + "pfp": "", + "username": "Bob" + }, + "timestamp": { + "$date": "2025-03-15T17:50:02.899Z" + }, + "comments": [], + "menuItem": "000000000000000000000004", + "restaurantId": { + "$oid": "000000000000000000000007" + } + } +}); diff --git a/backend/internal/handlers/restaurant/service.go b/backend/internal/handlers/restaurant/service.go index 20ba929d..cbe6b1a8 100644 --- a/backend/internal/handlers/restaurant/service.go +++ b/backend/internal/handlers/restaurant/service.go @@ -207,16 +207,9 @@ func (s *Service) GetRestaurantFriendsFav(uid primitive.ObjectID, rid primitive. } avgRating = float64(totalRating) / float64(numOfRatings) // the restaurant is a friends fav - if avgRating >= 4 { - friendsFav = &FriendsFav{ - IsFriendsFav: true, - FriendsReviewed: len(friends), - } - } else { - friendsFav = &FriendsFav{ - IsFriendsFav: false, - FriendsReviewed: len(friends), - } + friendsFav = &FriendsFav{ + IsFriendsFav: avgRating >= 4, + FriendsReviewed: len(friends), } return friendsFav, nil @@ -238,13 +231,9 @@ func (s *Service) GetSuperStars(rid primitive.ObjectID) (int, error) { var restaurant RestaurantDocument var superStars int - // restaurant is stored in doc err := s.restaurants.FindOne(ctx, bson.M{"_id": rid}).Decode(&restaurant) - if err == mongo.ErrNoDocuments { - // No matching restaurant found - return 0, mongo.ErrNoDocuments - } else if err != nil { - // Different error occurred + if err != nil { + // error occurred return 0, err } for _, menuItemId := range restaurant.MenuItems { From 331e2c534a4261538a1005f7e62636d23e78e809 Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Wed, 26 Mar 2025 00:14:07 -0400 Subject: [PATCH 07/26] made all changes from pr --- .../internal/handlers/restaurant/service.go | 19 ++++-------- backend/internal/handlers/review/types.go | 2 +- .../handlers/user_connections/service.go | 29 ++++--------------- .../user_connections/user_connections.go | 12 +++++++- backend/xutils/xutils.go | 12 ++++++++ 5 files changed, 34 insertions(+), 40 deletions(-) diff --git a/backend/internal/handlers/restaurant/service.go b/backend/internal/handlers/restaurant/service.go index cbe6b1a8..2ae85ff1 100644 --- a/backend/internal/handlers/restaurant/service.go +++ b/backend/internal/handlers/restaurant/service.go @@ -9,8 +9,10 @@ import ( "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" + "github.com/GenerateNU/platemate/xutils" ) + type Service struct { restaurants *mongo.Collection userConnectionsService *user_connections.Service @@ -174,7 +176,7 @@ func (s *Service) GetRestaurantFriendsFav(uid primitive.ObjectID, rid primitive. ctx := context.Background() var restaurant RestaurantDocument - var friends []string + var friends []primitive.ObjectID var totalRating int var numOfRatings int var avgRating float64 @@ -189,8 +191,7 @@ func (s *Service) GetRestaurantFriendsFav(uid primitive.ObjectID, rid primitive. return nil, err } for _, menuItemId := range restaurant.MenuItems { - // why are the inputs for this method strings when all id's are represented as ObjectID's ??? - friendReviews, err := s.userConnectionsService.GetFriendReviewsForItem(uid.Hex(), menuItemId.Hex()) + friendReviews, err := s.userConnectionsService.GetFriendReviewsForItem(uid, menuItemId) if err != nil { return nil, err } @@ -198,7 +199,7 @@ func (s *Service) GetRestaurantFriendsFav(uid primitive.ObjectID, rid primitive. // storing the friends of the user that reviewed menuItems at the restaurant friend := friendReview.Reviewer.ID // ensure that all the friends added to the list are unique - if doesNotContain(friends, friend) { + if xutils.DoesNotContain(friends, friend) { friends = append(friends, friend) } totalRating += friendReview.Rating.Overall @@ -215,16 +216,6 @@ func (s *Service) GetRestaurantFriendsFav(uid primitive.ObjectID, rid primitive. return friendsFav, nil } -// checks if an element is not in a slice -func doesNotContain(slice []string, item string) bool { - for _, element := range slice { - if element == item { - return false - } - } - return true -} - func (s *Service) GetSuperStars(rid primitive.ObjectID) (int, error) { ctx := context.Background() diff --git a/backend/internal/handlers/review/types.go b/backend/internal/handlers/review/types.go index a5c3f2fb..2c41fbc2 100644 --- a/backend/internal/handlers/review/types.go +++ b/backend/internal/handlers/review/types.go @@ -39,7 +39,7 @@ type Rating struct { // Reviewer is a nested struct in ReviewDocument. type Reviewer struct { - ID string `bson:"_id" json:"_id"` + ID primitive.ObjectID `bson:"_id" json:"_id"` PFP string `bson:"pfp" json:"pfp"` Username string `bson:"username" json:"username"` } diff --git a/backend/internal/handlers/user_connections/service.go b/backend/internal/handlers/user_connections/service.go index d32642e2..07bbbe69 100644 --- a/backend/internal/handlers/user_connections/service.go +++ b/backend/internal/handlers/user_connections/service.go @@ -3,6 +3,7 @@ package user_connections import ( "context" "errors" + "github.com/GenerateNU/platemate/internal/handlers/menu_items" "github.com/GenerateNU/platemate/internal/handlers/review" "github.com/GenerateNU/platemate/internal/xerr" @@ -327,24 +328,13 @@ func (s *Service) GetFollowingReviewsForItem(userId string, menuItemId string) ( // GetFriendReviewsForItem gets reviews for a specific menu item from friends (users that follow each other) // AKA users in the current user's following and follower list -func (s *Service) GetFriendReviewsForItem(userId string, menuItemId string) ([]review.ReviewDocument, error) { +func (s *Service) GetFriendReviewsForItem(userObjID primitive.ObjectID, menuItemObjID primitive.ObjectID) ([]review.ReviewDocument, error) { ctx := context.Background() - userObjID, err := primitive.ObjectIDFromHex(userId) - if err != nil { - badReq := xerr.BadRequest(err) - return nil, &badReq - } - - menuItemObjID, err := primitive.ObjectIDFromHex(menuItemId) - if err != nil { - badReq := xerr.BadRequest(err) - return nil, &badReq - } // Get the menu item to check its reviews var menuItem menu_items.MenuItemDocument - err = s.menuItems.FindOne(ctx, bson.M{"_id": menuItemObjID}).Decode(&menuItem) + err := s.menuItems.FindOne(ctx, bson.M{"_id": menuItemObjID}).Decode(&menuItem) if err != nil { if err == mongo.ErrNoDocuments { return []review.ReviewDocument{}, nil @@ -362,16 +352,7 @@ func (s *Service) GetFriendReviewsForItem(userId string, menuItemId string) ([]r if len(user.Following) == 0 || len(user.Followers) == 0 { // No friends :( return []review.ReviewDocument{}, nil } - // ADDED to account for type mismatch - var followingIDs []string - for _, id := range user.Following { - followingIDs = append(followingIDs, id.Hex()) // Convert ObjectID to string - } - var followerIDs []string - for _, id := range user.Followers { - followerIDs = append(followerIDs, id.Hex()) // Convert ObjectID to string - } // Query reviews that match both menu item reviews and friends (follower and following) reviewsCursor, err := s.reviews.Find(ctx, bson.M{ @@ -380,10 +361,10 @@ func (s *Service) GetFriendReviewsForItem(userId string, menuItemId string) ([]r "_id": bson.M{"$in": menuItem.Reviews}, // Match reviews related to the menu item }, { - "reviewer._id": bson.M{"$in": followingIDs}, // Reviewer's ID must be in the Following list + "reviewer._id": bson.M{"$in": user.Following}, // Reviewer's ID must be in the Following list }, { - "reviewer._id": bson.M{"$in": followerIDs}, // Reviewer's ID must be in the Followers list + "reviewer._id": bson.M{"$in": user.Followers}, // Reviewer's ID must be in the Followers list }, }, }, diff --git a/backend/internal/handlers/user_connections/user_connections.go b/backend/internal/handlers/user_connections/user_connections.go index 6eb77461..3096c2b9 100644 --- a/backend/internal/handlers/user_connections/user_connections.go +++ b/backend/internal/handlers/user_connections/user_connections.go @@ -7,6 +7,7 @@ import ( "github.com/GenerateNU/platemate/internal/xvalidator" "github.com/gofiber/fiber/v2" "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/bson/primitive" ) type Handler struct { @@ -85,7 +86,16 @@ func (h *Handler) GetFriendReviewsForItem(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(errs) } - reviews, err := h.service.GetFriendReviewsForItem(query.UserId, query.ItemId) + userIdObj, err := primitive.ObjectIDFromHex(query.UserId) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(xerr.BadRequest(err)) + } + itemIdObj, err := primitive.ObjectIDFromHex(query.ItemId) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(xerr.BadRequest(err)) + } + + reviews, err := h.service.GetFriendReviewsForItem(userIdObj, itemIdObj) if err != nil { if errors.Is(err, mongo.ErrNoDocuments) { return c.Status(fiber.StatusNotFound).JSON(xerr.NotFound("User", "id", query.UserId)) diff --git a/backend/xutils/xutils.go b/backend/xutils/xutils.go index 058b7433..b96b1efe 100644 --- a/backend/xutils/xutils.go +++ b/backend/xutils/xutils.go @@ -1,6 +1,8 @@ package xutils import "crypto/rand" +import "go.mongodb.org/mongo-driver/bson/primitive" + func GenerateOTP(length int) (string, error) { @@ -21,3 +23,13 @@ func GenerateOTP(length int) (string, error) { return string(otp), nil } + +// checks if an element is not in a slice +func DoesNotContain(slice []primitive.ObjectID, item primitive.ObjectID) bool { + for _, element := range slice { + if element == item { + return false + } + } + return true +} \ No newline at end of file From a8609be64e6585badffeaef06dd5201a9fccce42 Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Wed, 26 Mar 2025 00:52:49 -0400 Subject: [PATCH 08/26] changed imports --- backend/internal/handlers/restaurant/routes.go | 4 ++-- backend/internal/handlers/restaurant/service.go | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/backend/internal/handlers/restaurant/routes.go b/backend/internal/handlers/restaurant/routes.go index 36a4b1f5..b492ae7c 100644 --- a/backend/internal/handlers/restaurant/routes.go +++ b/backend/internal/handlers/restaurant/routes.go @@ -3,13 +3,13 @@ package restaurant import ( "github.com/GenerateNU/platemate/internal/handlers/menu_items" "github.com/GenerateNU/platemate/internal/handlers/review" - "github.com/GenerateNU/platemate/internal/handlers/user_connections" + "github.com/GenerateNU/platemate/internal/handlers/users" "github.com/gofiber/fiber/v2" "go.mongodb.org/mongo-driver/mongo" ) func Routes(app *fiber.App, collections map[string]*mongo.Collection) { - userConnectionsService := user_connections.NewService(collections) + userConnectionsService := users.NewService(collections) menuItemsService := menu_items.NewService(collections) reviewService := review.NewService(collections) service := newService(collections, userConnectionsService, menuItemsService, reviewService) diff --git a/backend/internal/handlers/restaurant/service.go b/backend/internal/handlers/restaurant/service.go index 2ae85ff1..624bc2e8 100644 --- a/backend/internal/handlers/restaurant/service.go +++ b/backend/internal/handlers/restaurant/service.go @@ -5,22 +5,21 @@ import ( "github.com/GenerateNU/platemate/internal/handlers/menu_items" "github.com/GenerateNU/platemate/internal/handlers/review" - "github.com/GenerateNU/platemate/internal/handlers/user_connections" + "github.com/GenerateNU/platemate/internal/handlers/users" + "github.com/GenerateNU/platemate/xutils" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" - "github.com/GenerateNU/platemate/xutils" ) - type Service struct { restaurants *mongo.Collection - userConnectionsService *user_connections.Service + userConnectionsService *users.Service menuItemsService *menu_items.Service reviewService *review.Service } -func newService(collections map[string]*mongo.Collection, userConnectionsService *user_connections.Service, menuItemsService *menu_items.Service, reviewService *review.Service) *Service { +func newService(collections map[string]*mongo.Collection, userConnectionsService *users.Service, menuItemsService *menu_items.Service, reviewService *review.Service) *Service { return &Service{ restaurants: collections["restaurants"], userConnectionsService: userConnectionsService, From cc41bd994f96185a7dd0c9c28587a29f22dbc3e8 Mon Sep 17 00:00:00 2001 From: Garrett Mitchell Ladley Date: Sat, 29 Mar 2025 08:53:50 -0400 Subject: [PATCH 09/26] feat: FEATURETHON.md --- FEATURETHON.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 FEATURETHON.md diff --git a/FEATURETHON.md b/FEATURETHON.md new file mode 100644 index 00000000..5af7cc33 --- /dev/null +++ b/FEATURETHON.md @@ -0,0 +1,3 @@ +# Featurethon + + From 11838926b5858bcce3a0ddfd3f23defd9ec2d073 Mon Sep 17 00:00:00 2001 From: Ben Petrillo Date: Sat, 29 Mar 2025 11:16:18 -0400 Subject: [PATCH 10/26] chore: refactor frontend hierarchy --- frontend/app/MenuItemView.tsx | 4 +- frontend/app/RestaurantView.tsx | 60 ++++++-- frontend/components/Cards/MenuItemCard.tsx | 4 +- frontend/components/MyReview.tsx | 2 +- frontend/components/icons/Icons.tsx | 62 +++++++- .../components/restaurant/HighlightCard.tsx | 54 +++++++ .../restaurant/RestaurantDetailItem.tsx | 2 +- .../restaurant/RestaurantReviewSummary.tsx | 141 ++++++++++++++++++ frontend/components/review/ReviewDetail.tsx | 2 +- frontend/components/{ => ui}/StarReview.tsx | 15 +- frontend/components/ui/Tag.tsx | 25 ++++ 11 files changed, 344 insertions(+), 27 deletions(-) create mode 100644 frontend/components/restaurant/HighlightCard.tsx create mode 100644 frontend/components/restaurant/RestaurantReviewSummary.tsx rename frontend/components/{ => ui}/StarReview.tsx (87%) create mode 100644 frontend/components/ui/Tag.tsx diff --git a/frontend/app/MenuItemView.tsx b/frontend/app/MenuItemView.tsx index 84f97955..ea30ee37 100644 --- a/frontend/app/MenuItemView.tsx +++ b/frontend/app/MenuItemView.tsx @@ -1,7 +1,7 @@ import { ThemedView } from "@/components/themed/ThemedView"; import { ScrollView, StyleSheet, View, Image, Pressable } from "react-native"; import { ThemedText } from "@/components/themed/ThemedText"; -import { StarReview } from "@/components/StarReview"; +import { StarRating } from "@/components/ui/StarReview"; import React from "react"; import { Ionicons } from "@expo/vector-icons"; import ReviewPreview from "@/components/review/ReviewPreview"; @@ -128,7 +128,7 @@ export default function MenuItemView() { 4/5 - + diff --git a/frontend/app/RestaurantView.tsx b/frontend/app/RestaurantView.tsx index 9f7af20e..64291ab6 100644 --- a/frontend/app/RestaurantView.tsx +++ b/frontend/app/RestaurantView.tsx @@ -1,13 +1,15 @@ import { ThemedView } from "@/components/themed/ThemedView"; import { ScrollView, StyleSheet, View } from "react-native"; import { ThemedText } from "@/components/themed/ThemedText"; -import { RestaurantTags } from "@/components/RestaurantTags"; -import { StarReview } from "@/components/StarReview"; import React from "react"; -import { PhoneIcon, WebsiteIcon } from "@/components/icons/Icons"; +import { PersonWavingIcon, PhoneIcon, ThumbsUpIcon, WebsiteIcon } from "@/components/icons/Icons"; import { RestaurantDetailItem } from "@/components/restaurant/RestaurantDetailItem"; import BannerAndAvatar from "@/components/restaurant/RestaurantBanner"; +import Tag from "@/components/ui/Tag"; +import { StarRating } from "@/components/ui/StarReview"; +import RestaurantReviewSummary from "@/components/restaurant/RestaurantReviewSummary"; +import HighlightCard from "@/components/restaurant/HighlightCard"; export default function RestaurantView() { const restaurantTags = ["Fast Food", "Fried Chicken", "Chicken Sandwiches", "Order Online"]; @@ -25,7 +27,7 @@ export default function RestaurantView() { - + @@ -33,9 +35,34 @@ export default function RestaurantView() { - - - + + {restaurantTags.map((tag, index) => ( + + + + ))} + + + + + + } + /> + } /> + + ); @@ -60,9 +87,13 @@ const styles = StyleSheet.create({ ratingContainer: { paddingVertical: 4, }, - tagsContainer: { - paddingVertical: 8, - gap: 4, + tagsScrollViewContent: { + flexDirection: "row", + paddingTop: 8, + paddingBottom: 12, + }, + tagWrapper: { + marginRight: 4, }, chefsPickContainer: { paddingVertical: 4, @@ -86,4 +117,11 @@ const styles = StyleSheet.create({ borderTopLeftRadius: 32, borderTopRightRadius: 32, }, -}); + highlightsContainer: { + flex: 1, + paddingVertical: 12, + flexDirection: "row", + justifyContent: "space-between", + gap: 12, + }, +}); \ No newline at end of file diff --git a/frontend/components/Cards/MenuItemCard.tsx b/frontend/components/Cards/MenuItemCard.tsx index 1d666ea1..792af5fc 100644 --- a/frontend/components/Cards/MenuItemCard.tsx +++ b/frontend/components/Cards/MenuItemCard.tsx @@ -1,8 +1,8 @@ import React from "react"; import { View, Text } from "react-native"; import { StyleSheet } from "react-native"; -import { StarReview } from "@/components/StarReview"; -import { StarReviewProps } from "@/components/StarReview"; +import { StarReview } from "@/components/ui/StarReview"; +import { StarReviewProps } from "@/components/ui/StarReview"; import { Image } from "react-native"; interface MenuItemProp { diff --git a/frontend/components/MyReview.tsx b/frontend/components/MyReview.tsx index 3404fab2..498075a0 100644 --- a/frontend/components/MyReview.tsx +++ b/frontend/components/MyReview.tsx @@ -3,7 +3,7 @@ import { View, Text, StyleSheet, Image, TextInput, TouchableOpacity, SafeAreaVie import { IconSymbol } from "../components/ui/IconSymbol"; import { ProgressBar } from "./ProgressBar"; import { EmojiTagsGrid } from "./EmojiTagsGrid"; -import { InteractiveStars } from "./StarReview"; +import { InteractiveStars } from "./ui/StarReview"; export function MyReview() { const [step, setStep] = useState(1); diff --git a/frontend/components/icons/Icons.tsx b/frontend/components/icons/Icons.tsx index 4239eac3..1b6058c6 100644 --- a/frontend/components/icons/Icons.tsx +++ b/frontend/components/icons/Icons.tsx @@ -34,7 +34,7 @@ export const WebsiteIcon = ({ width = 20, height = 20, color = "black", strokeWi ); }; -export const MarkerIcon = ({ width = 20, height = 20, color = "black", ...props }) => { +export const MarkerIcon = ({ width = 20, height = 20, color = "#fc0", ...props }) => { return ( { +export const ClockIcon = ({ width = 20, height = 20, color = "black", ...props }) => { return ( { ); }; + +export const StarIcon = ({ width = 12, height = 12, color = "#fc0", strokeWidth = 0.8, filled = false, ...props }) => { + return ( + + + + ); +}; + +export const SmileyIcon = ({ width = 35, height = 35, color = "#FFCF0F", ...props }) => { + return ( + + + + + + ); +}; + +export const ThumbsUpIcon = ({ width = 35, height = 35, color = "#FFCF0F", ...props }) => { + return ( + + + + ); +}; + +export const PersonWavingIcon = ({ width = 35, height = 35, color = "#FDD329", ...props }) => { + return ( + + + + + ); +}; diff --git a/frontend/components/restaurant/HighlightCard.tsx b/frontend/components/restaurant/HighlightCard.tsx new file mode 100644 index 00000000..432ed5f8 --- /dev/null +++ b/frontend/components/restaurant/HighlightCard.tsx @@ -0,0 +1,54 @@ +import { SmileyIcon } from "@/components/icons/Icons"; +import { View, Text, StyleSheet } from "react-native"; + +const HighlightCard = ({ + icon = , + title = "Super Stars", + subtitle = "200+ Five Stars", + backgroundColor = "#F7F9FC", + }) => { + return ( + + {icon} + + {title} + {subtitle} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + padding: 8, + borderRadius: 8, + width: 120, + height: 120, + flex: 1, + }, + iconContainer: { + marginBottom: 8, + }, + textContainer: { + alignItems: "center", + }, + title: { + fontSize: 12, + fontWeight: "bold", + color: "#000000", + textAlign: "center", + marginBottom: 4, + fontFamily: "Outfit", + }, + subtitle: { + fontSize: 12, + color: "#666666", + textAlign: "center", + fontFamily: "Outfit", + }, +}); + +export default HighlightCard; \ No newline at end of file diff --git a/frontend/components/restaurant/RestaurantDetailItem.tsx b/frontend/components/restaurant/RestaurantDetailItem.tsx index 970064dc..7236301d 100644 --- a/frontend/components/restaurant/RestaurantDetailItem.tsx +++ b/frontend/components/restaurant/RestaurantDetailItem.tsx @@ -24,7 +24,7 @@ const styles = StyleSheet.create({ }, text: { fontSize: 14, - color: "#285852", + color: "black", fontFamily: "Outfit", fontWeight: 500, }, diff --git a/frontend/components/restaurant/RestaurantReviewSummary.tsx b/frontend/components/restaurant/RestaurantReviewSummary.tsx new file mode 100644 index 00000000..1fa4ade9 --- /dev/null +++ b/frontend/components/restaurant/RestaurantReviewSummary.tsx @@ -0,0 +1,141 @@ +import React from "react"; +import { View, Text, StyleSheet } from "react-native"; +import { StarIcon } from "@/components/icons/Icons"; + +const ReviewSummary = ({ + rating = 4, + maxRating = 5, + reviewCount = 300, + friendsReviewCount = 3, + highlight = "Best Pad Thai in Boston. I'm serious.", + }) => { + return ( + + + + {rating} + /{maxRating} + + + + + {[...Array(maxRating)].map((_, index) => ( + + ))} + + {reviewCount} total reviews + + + + + + + "{highlight}" + + + {[...Array(Math.min(friendsReviewCount, 3))].map((_, index) => ( + + ))} + + + {friendsReviewCount} friend{friendsReviewCount !== 1 ? "s" : ""} reviewed recently + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: "#F9FAFB", + borderRadius: 12, + padding: 16, + width: "100%", + }, + ratingSection: { + flexDirection: "row", + alignItems: "center", + }, + ratingCircle: { + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: "#F8F8F8", + justifyContent: "center", + alignItems: "center", + flexDirection: "row", + marginRight: 16, + }, + ratingText: { + fontSize: 28, + fontWeight: "bold", + color: "#222222", + fontFamily: "Outfit", + }, + maxRatingText: { + fontSize: 18, + color: "#666666", + fontWeight: "500", + marginTop: 2, + fontFamily: "Outfit", + }, + starsAndCountSection: { + flex: 1, + }, + starsRow: { + flexDirection: "row", + alignItems: "center", + marginBottom: 6, + gap: 4, + }, + reviewCount: { + fontSize: 14, + color: "#666666", + fontWeight: "500", + fontFamily: "Outfit", + }, + divider: { + height: 1, + backgroundColor: "#EEEEEE", + marginVertical: 8, + }, + highlightSection: { + flexDirection: "column", + }, + highlightText: { + fontSize: 16, + fontStyle: "italic", + color: "#222222", + marginBottom: 12, + lineHeight: 22, + fontFamily: "Outfit", + }, + friendsContainer: { + flexDirection: "row", + alignItems: "center", + }, + avatarStack: { + height: 30, + width: 50, + position: "relative", + }, + friendAvatar: { + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: "#E0E0E0", + borderWidth: 2, + borderColor: "#FFFFFF", + position: "absolute", + top: 0, + }, + friendsText: { + fontSize: 13, + color: "#666666", + marginLeft: 6, + fontFamily: "Outfit", + }, +}); + +export default ReviewSummary; \ No newline at end of file diff --git a/frontend/components/review/ReviewDetail.tsx b/frontend/components/review/ReviewDetail.tsx index 5cef4784..ba90dc0e 100644 --- a/frontend/components/review/ReviewDetail.tsx +++ b/frontend/components/review/ReviewDetail.tsx @@ -2,7 +2,7 @@ import React from "react"; import { View, StyleSheet, ScrollView, Image, TouchableOpacity } from "react-native"; import { ThemedView } from "@/components/themed/ThemedView"; import { ThemedText } from "@/components/themed/ThemedText"; -import { StarReview } from "@/components/StarReview"; +import { StarReview } from "@/components/ui/StarReview"; import { Ionicons } from "@expo/vector-icons"; // import { RestaurantTags } from "@/components/RestaurantTags"; import { Entypo } from "@expo/vector-icons"; diff --git a/frontend/components/StarReview.tsx b/frontend/components/ui/StarReview.tsx similarity index 87% rename from frontend/components/StarReview.tsx rename to frontend/components/ui/StarReview.tsx index 15a67625..8e589c35 100644 --- a/frontend/components/StarReview.tsx +++ b/frontend/components/ui/StarReview.tsx @@ -3,6 +3,7 @@ import { View, Text, TouchableOpacity } from "react-native"; import ShadedStar from "@/assets/icons/shaded_star_rate.svg"; import UnshadedStar from "@/assets/icons/unshaded_star_rate.svg"; import { StyleSheet } from "react-native"; +import { StarIcon } from "@/components/icons/Icons"; export interface StarReviewProps { avgRating: number; @@ -26,7 +27,7 @@ interface StarProps { full?: boolean; } -export function StarReview({ +export function StarRating({ avgRating, numRatings, full = true, @@ -49,18 +50,18 @@ export function StarReview({ } export function Stars({ avgRating, full = true }: StarProps) { - const stars = []; + const stars: React.JSX.Element[] = []; const maxStars = full ? 5 : 1; if (full) { for (let i = 0; i < maxStars; i++) { if (i < Math.floor(avgRating)) { - stars.push(); + stars.push(); } else { - stars.push(); + stars.push(); } } } else { - stars.push(); + stars.push(); } return {stars}; @@ -81,7 +82,7 @@ export function InteractiveStars({ const StarIcon = isFilled ? ShadedStar : UnshadedStar; return ( onChange(i + 1)}> - + ); })} @@ -109,7 +110,7 @@ const styles = StyleSheet.create({ flexDirection: "row", alignItems: "center", justifyContent: "center", - gap: 5, + gap: 3, }, starRow: { flexDirection: "row", diff --git a/frontend/components/ui/Tag.tsx b/frontend/components/ui/Tag.tsx new file mode 100644 index 00000000..f3fa031b --- /dev/null +++ b/frontend/components/ui/Tag.tsx @@ -0,0 +1,25 @@ +import { StyleSheet, Text } from "react-native"; + +type TagProps = { + text: string; + color?: string; +}; + +const Tag = ({ text, color = "#fc0" }: TagProps) => { + return {text}; +}; + +const styles = StyleSheet.create({ + tag: { + color: "#000", + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 20, + fontSize: 12, + fontFamily: "Outfit", + fontWeight: "bold", + marginRight: 0, + }, +}); + +export default Tag; \ No newline at end of file From 0f9b89f02029fd0182eb57ec62be856aa762b342 Mon Sep 17 00:00:00 2001 From: Ben Petrillo Date: Sat, 29 Mar 2025 11:46:28 -0400 Subject: [PATCH 11/26] chore: menu item view styling fixes --- frontend/app/MenuItemView.tsx | 48 ++++++++++++++++---- frontend/app/RestaurantView.tsx | 25 ++++++++++ frontend/components/review/ReviewPreview.tsx | 3 +- 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/frontend/app/MenuItemView.tsx b/frontend/app/MenuItemView.tsx index ea30ee37..62c3a5ec 100644 --- a/frontend/app/MenuItemView.tsx +++ b/frontend/app/MenuItemView.tsx @@ -10,6 +10,8 @@ import { ThemedTag } from "@/components/themed/ThemedTag"; // import { RestaurantTags } from "@/components/RestaurantTags"; import { StatCard } from "@/components/Cards/StatCard"; import { ReviewButton } from "@/components/review/ReviewButton"; +import HighlightCard from "@/components/restaurant/HighlightCard"; +import { PersonWavingIcon, ThumbsUpIcon } from "@/components/icons/Icons"; // Temporary icons - you may want to create proper icons const FriendsIcon = () => ( @@ -96,11 +98,13 @@ export default function MenuItemView() { flavorful Thai stir-fried noodle dish with a perfect sweet-savory balance. - - Rice noodles, eggs, tofu/shrimp, peanuts, tamarind. - - - see allergy + + + Rice noodles, eggs, tofu/shrimp, peanuts, tamarind + + + + see allergens @@ -113,9 +117,13 @@ export default function MenuItemView() { - } title="Friends' Fav" subtitle="100+ Friends' refers" /> - } title="Super Stars" subtitle="200+ Five Stars" /> - } title="Satisfaction" subtitle="70% revisited" /> + } + /> + } /> + {/* Reviews Section */} @@ -224,15 +232,34 @@ const styles = StyleSheet.create({ fontSize: 16, lineHeight: 16, }, + allergyLabel: { + fontSize: 14, + fontWeight: "600", + color: "#444", + lineHeight: 18, + }, allergyText: { fontSize: 14, color: "#666", - lineHeight: 14, + lineHeight: 18, + flexShrink: 1, }, allergyRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", + marginTop: 4, + }, + allergyItemsContainer: { + flexDirection: "row", + alignItems: "center", + flexShrink: 1, + flexWrap: "wrap", + maxWidth: "75%", + }, + allergyButton: { + paddingVertical: 4, + paddingHorizontal: 8, }, sectionHeader: { flexDirection: "row", @@ -247,6 +274,7 @@ const styles = StyleSheet.create({ viewAllText: { color: "#007AFF", textDecorationLine: "underline", + fontSize: 14, }, statsContainer: { flexDirection: "row", @@ -301,4 +329,4 @@ const styles = StyleSheet.create({ tagRow: { flexDirection: "row", }, -}); +}); \ No newline at end of file diff --git a/frontend/app/RestaurantView.tsx b/frontend/app/RestaurantView.tsx index 64291ab6..e2ff7fa4 100644 --- a/frontend/app/RestaurantView.tsx +++ b/frontend/app/RestaurantView.tsx @@ -10,9 +10,15 @@ import Tag from "@/components/ui/Tag"; import { StarRating } from "@/components/ui/StarReview"; import RestaurantReviewSummary from "@/components/restaurant/RestaurantReviewSummary"; import HighlightCard from "@/components/restaurant/HighlightCard"; +import FeedTabs from "@/components/Feed/FeedTabs"; +import { filter } from "domutils"; +import ReviewPreview from "@/components/review/ReviewPreview"; +import MenuItemPreview from "@/components/Cards/MenuItemPreview"; export default function RestaurantView() { const restaurantTags = ["Fast Food", "Fried Chicken", "Chicken Sandwiches", "Order Online"]; + const [activeTab, setActiveTab] = React.useState(0); + const [filterTab, setFilterTab] = React.useState(0); return ( @@ -63,6 +69,25 @@ export default function RestaurantView() { } /> + + + + + { filterTab == 0 && ( + <> + + + + + + )} + + { filterTab == 1 && ( + <> + + + )} + ); diff --git a/frontend/components/review/ReviewPreview.tsx b/frontend/components/review/ReviewPreview.tsx index 4edb9162..2ed188aa 100644 --- a/frontend/components/review/ReviewPreview.tsx +++ b/frontend/components/review/ReviewPreview.tsx @@ -27,7 +27,7 @@ const ReviewPreview = ({ plateName, restaurantName, tags, rating, content }: Pro borderRadius: 12, paddingTop: 24, // width: Dimensions.get("window").width * 0.75, - height: Dimensions.get("window").height * 0.4, + height: Dimensions.get("window").height * 0.35, }}> Date: Sat, 29 Mar 2025 11:46:47 -0400 Subject: [PATCH 12/26] chore: format code --- frontend/app/MenuItemView.tsx | 2 +- frontend/app/RestaurantView.tsx | 33 +++++++++++++++---- .../components/restaurant/HighlightCard.tsx | 12 +++---- .../restaurant/RestaurantReviewSummary.tsx | 14 ++++---- frontend/components/ui/StarReview.tsx | 11 +++++-- frontend/components/ui/Tag.tsx | 2 +- 6 files changed, 50 insertions(+), 24 deletions(-) diff --git a/frontend/app/MenuItemView.tsx b/frontend/app/MenuItemView.tsx index 62c3a5ec..e5634708 100644 --- a/frontend/app/MenuItemView.tsx +++ b/frontend/app/MenuItemView.tsx @@ -329,4 +329,4 @@ const styles = StyleSheet.create({ tagRow: { flexDirection: "row", }, -}); \ No newline at end of file +}); diff --git a/frontend/app/RestaurantView.tsx b/frontend/app/RestaurantView.tsx index e2ff7fa4..4103bdb3 100644 --- a/frontend/app/RestaurantView.tsx +++ b/frontend/app/RestaurantView.tsx @@ -73,18 +73,39 @@ export default function RestaurantView() { - { filterTab == 0 && ( + {filterTab == 0 && ( <> - + - + )} - { filterTab == 1 && ( + {filterTab == 1 && ( <> - + )} @@ -149,4 +170,4 @@ const styles = StyleSheet.create({ justifyContent: "space-between", gap: 12, }, -}); \ No newline at end of file +}); diff --git a/frontend/components/restaurant/HighlightCard.tsx b/frontend/components/restaurant/HighlightCard.tsx index 432ed5f8..91e08697 100644 --- a/frontend/components/restaurant/HighlightCard.tsx +++ b/frontend/components/restaurant/HighlightCard.tsx @@ -2,11 +2,11 @@ import { SmileyIcon } from "@/components/icons/Icons"; import { View, Text, StyleSheet } from "react-native"; const HighlightCard = ({ - icon = , - title = "Super Stars", - subtitle = "200+ Five Stars", - backgroundColor = "#F7F9FC", - }) => { + icon = , + title = "Super Stars", + subtitle = "200+ Five Stars", + backgroundColor = "#F7F9FC", +}) => { return ( {icon} @@ -51,4 +51,4 @@ const styles = StyleSheet.create({ }, }); -export default HighlightCard; \ No newline at end of file +export default HighlightCard; diff --git a/frontend/components/restaurant/RestaurantReviewSummary.tsx b/frontend/components/restaurant/RestaurantReviewSummary.tsx index 1fa4ade9..0974b4e8 100644 --- a/frontend/components/restaurant/RestaurantReviewSummary.tsx +++ b/frontend/components/restaurant/RestaurantReviewSummary.tsx @@ -3,12 +3,12 @@ import { View, Text, StyleSheet } from "react-native"; import { StarIcon } from "@/components/icons/Icons"; const ReviewSummary = ({ - rating = 4, - maxRating = 5, - reviewCount = 300, - friendsReviewCount = 3, - highlight = "Best Pad Thai in Boston. I'm serious.", - }) => { + rating = 4, + maxRating = 5, + reviewCount = 300, + friendsReviewCount = 3, + highlight = "Best Pad Thai in Boston. I'm serious.", +}) => { return ( @@ -138,4 +138,4 @@ const styles = StyleSheet.create({ }, }); -export default ReviewSummary; \ No newline at end of file +export default ReviewSummary; diff --git a/frontend/components/ui/StarReview.tsx b/frontend/components/ui/StarReview.tsx index 8e589c35..670e01f7 100644 --- a/frontend/components/ui/StarReview.tsx +++ b/frontend/components/ui/StarReview.tsx @@ -55,9 +55,9 @@ export function Stars({ avgRating, full = true }: StarProps) { if (full) { for (let i = 0; i < maxStars; i++) { if (i < Math.floor(avgRating)) { - stars.push(); + stars.push(); } else { - stars.push(); + stars.push(); } } } else { @@ -82,7 +82,12 @@ export function InteractiveStars({ const StarIcon = isFilled ? ShadedStar : UnshadedStar; return ( onChange(i + 1)}> - + ); })} diff --git a/frontend/components/ui/Tag.tsx b/frontend/components/ui/Tag.tsx index f3fa031b..56a0e039 100644 --- a/frontend/components/ui/Tag.tsx +++ b/frontend/components/ui/Tag.tsx @@ -22,4 +22,4 @@ const styles = StyleSheet.create({ }, }); -export default Tag; \ No newline at end of file +export default Tag; From a257d952ca2b6eb98c833ce4128308aef482289a Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Sat, 29 Mar 2025 13:51:04 -0400 Subject: [PATCH 13/26] added getDietaryRestrictions endpoint --- backend/internal/handlers/users/routes.go | 4 ++++ backend/internal/handlers/users/service.go | 20 +++++++++++++++++ backend/internal/handlers/users/types.go | 2 ++ .../handlers/users/user_connections.go | 16 ++++++++++++++ frontend/app/(tabs)/profile/followers.tsx | 2 +- frontend/app/(tabs)/profile/settings.tsx | 22 ++++++++----------- frontend/app/friend.tsx | 3 +-- .../components/profile/ProfileMetrics.tsx | 10 ++++++--- .../profile/followers/FollowButton.tsx | 4 ++-- 9 files changed, 62 insertions(+), 21 deletions(-) diff --git a/backend/internal/handlers/users/routes.go b/backend/internal/handlers/users/routes.go index 70232471..69fdee96 100644 --- a/backend/internal/handlers/users/routes.go +++ b/backend/internal/handlers/users/routes.go @@ -24,4 +24,8 @@ func Routes(app *fiber.App, collections map[string]*mongo.Collection) { item := apiV1.Group("/item") item.Get("/:id/followReviews", handler.GetFollowingReviewsForItem) item.Get("/:id/friendReviews", handler.GetFriendReviewsForItem) + + // User settings + settings := apiV1.Group("/settings") + settings.Get("/:id/dietaryPreferences", handler.GetDietaryPreferences) } diff --git a/backend/internal/handlers/users/service.go b/backend/internal/handlers/users/service.go index eb77a89e..02956e50 100644 --- a/backend/internal/handlers/users/service.go +++ b/backend/internal/handlers/users/service.go @@ -412,3 +412,23 @@ func (s *Service) GetFriendReviewsForItem(userObjID primitive.ObjectID, menuItem return reviews, nil } + +func (s *Service) GetDietaryPreferences(userId string) ([]string, error) { + ctx := context.Background() + userObjID, err := primitive.ObjectIDFromHex(userId) + if err != nil { + badReq := xerr.BadRequest(err) + return nil, &badReq + } + + // Find the user and get their followers + var user User + err = s.users.FindOne(ctx, bson.M{"_id": userObjID}).Decode(&user) + if err != nil { + return nil, err + } + + dietaryRestrictions := user.Preferences + + return dietaryRestrictions, nil +} diff --git a/backend/internal/handlers/users/types.go b/backend/internal/handlers/users/types.go index 2c42162b..2decf2ff 100644 --- a/backend/internal/handlers/users/types.go +++ b/backend/internal/handlers/users/types.go @@ -15,6 +15,7 @@ type User struct { FollowersCount int `bson:"followersCount"` ProfilePicture string `bson:"profile_picture,omitempty"` Name string `bson:"name,omitempty"` + Preferences []string `bson:"preferences,omitempty"` } type UserResponse struct { @@ -26,6 +27,7 @@ type UserResponse struct { FollowingCount int `json:"followingCount"` Reviews []string `json:"reviews,omitempty"` Name string `json:"name,omitempty"` + Preferences []string `json:"preferences,omitempty"` } type FollowRequest struct { diff --git a/backend/internal/handlers/users/user_connections.go b/backend/internal/handlers/users/user_connections.go index 33c7b876..5c024fbc 100644 --- a/backend/internal/handlers/users/user_connections.go +++ b/backend/internal/handlers/users/user_connections.go @@ -2,6 +2,7 @@ package users import ( "errors" + "github.com/GenerateNU/platemate/internal/xerr" "github.com/GenerateNU/platemate/internal/xvalidator" "github.com/gofiber/fiber/v2" @@ -180,3 +181,18 @@ func (h *Handler) UnfollowUser(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } + +// GetDietaryPreferences retrieves the dietary preferences of a user +func (h *Handler) GetDietaryPreferences(c *fiber.Ctx) error { + userId := c.Params("id") + + preferences, err := h.service.GetDietaryPreferences(userId) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return c.Status(fiber.StatusNotFound).JSON(xerr.NotFound("User", "id", userId)) + } + return err + } + + return c.JSON(preferences) +} diff --git a/frontend/app/(tabs)/profile/followers.tsx b/frontend/app/(tabs)/profile/followers.tsx index 8711f2cf..abd80918 100644 --- a/frontend/app/(tabs)/profile/followers.tsx +++ b/frontend/app/(tabs)/profile/followers.tsx @@ -122,7 +122,7 @@ export default function FollowersScreen() { {loading && page === 1 ? ( - Loading followers... + Loading friends... ) : ( { setSettings((prevSettings) => ({ ...prevSettings, @@ -116,14 +115,6 @@ export default function SettingsScreen() { onPress: () => router.push("/(tabs)/profile/followers"), showChevron: true, }, - { - label: "Logout", - onPress: () => { - logout(); - router.replace("/(onboarding)"); - }, - showChevron: false, - }, ], additional: [ { label: "Blocked Users", onPress: () => console.log("navigating to blocked users") }, @@ -131,6 +122,11 @@ export default function SettingsScreen() { ], }; + const handleLogOut = () => { + logout(); + router.replace("/(onboarding)"); + } + return ( @@ -196,7 +192,7 @@ export default function SettingsScreen() { ))} - + @@ -221,13 +217,13 @@ const styles = StyleSheet.create({ alignItems: "center", gap: 4, borderRadius: 25, - backgroundColor: "#285852", + backgroundColor: "#FFCF0F", alignSelf: "center", - width: 100, + width: 90, height: 30, }, buttonText: { - color: "#FFF", + color: "#000", textAlign: "center", fontFamily: "Source Sans 3", fontSize: 14, diff --git a/frontend/app/friend.tsx b/frontend/app/friend.tsx index 5b99e4b6..e43f10e7 100644 --- a/frontend/app/friend.tsx +++ b/frontend/app/friend.tsx @@ -8,7 +8,6 @@ import { Ionicons } from "@expo/vector-icons"; import ProfileAvatar from "@/components/profile/ProfileAvatar"; import ProfileIdentity from "@/components/profile/ProfileIdentity"; import ProfileMetrics from "@/components/profile/ProfileMetrics"; -import EditProfileSheet from "@/components/profile/EditProfileSheet"; import ReviewPreview from "@/components/review/ReviewPreview"; import { SearchBoxFilter } from "@/components/SearchBoxFilter"; import EditFriendSheet from "@/components/profile/followers/FriendProfileOptions"; @@ -68,7 +67,7 @@ const ProfileScreen = () => { - + diff --git a/frontend/components/profile/ProfileMetrics.tsx b/frontend/components/profile/ProfileMetrics.tsx index a49d531b..a1230783 100644 --- a/frontend/components/profile/ProfileMetrics.tsx +++ b/frontend/components/profile/ProfileMetrics.tsx @@ -1,7 +1,8 @@ -import { StyleSheet, View } from "react-native"; +import { StyleSheet, View, TouchableOpacity } from "react-native"; import { ThemedText } from "@/components/themed/ThemedText"; import { ThemedView } from "@/components/themed/ThemedView"; import React from "react"; +import { useRouter } from "expo-router"; type ProfileMetricProps = { numFriends: number; @@ -10,6 +11,7 @@ type ProfileMetricProps = { }; const ProfileMetrics = (props: ProfileMetricProps) => { + const router = useRouter(); return ( @@ -18,8 +20,10 @@ const ProfileMetrics = (props: ProfileMetricProps) => { - {props.numFriends} - friends + { router.push("/(tabs)/profile/followers") }}> + {props.numFriends} + friends + diff --git a/frontend/components/profile/followers/FollowButton.tsx b/frontend/components/profile/followers/FollowButton.tsx index 26a696b0..333436c0 100644 --- a/frontend/components/profile/followers/FollowButton.tsx +++ b/frontend/components/profile/followers/FollowButton.tsx @@ -8,12 +8,12 @@ export const FollowButton: React.FC<{ text: string }> = ({ text }) => { const [buttonText, setButtonText] = useState(text); const handlePress = () => { - if (buttonText == "Following") { + if (buttonText == "Friends") { setIsPressed(false); setButtonText("Follow"); } else { setIsPressed(true); - setButtonText("Following"); + setButtonText("Friends"); } }; From 73f836d0855fcacbbd4bf032d643b576dc4bc56c Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Sat, 29 Mar 2025 18:05:39 -0400 Subject: [PATCH 14/26] integrated the backend for the dietary preferences --- backend/internal/handlers/users/routes.go | 2 + backend/internal/handlers/users/service.go | 42 +++++ backend/internal/handlers/users/types.go | 4 + .../handlers/users/user_connections.go | 32 +++- frontend/app/(tabs)/profile/settings.tsx | 148 +++++++++++------- 5 files changed, 171 insertions(+), 57 deletions(-) diff --git a/backend/internal/handlers/users/routes.go b/backend/internal/handlers/users/routes.go index 69fdee96..a91f4d23 100644 --- a/backend/internal/handlers/users/routes.go +++ b/backend/internal/handlers/users/routes.go @@ -28,4 +28,6 @@ func Routes(app *fiber.App, collections map[string]*mongo.Collection) { // User settings settings := apiV1.Group("/settings") settings.Get("/:id/dietaryPreferences", handler.GetDietaryPreferences) + settings.Post("/:id/dietaryPreferences", handler.PostDietaryPreferences) + settings.Delete("/:id/dietaryPreferences", handler.DeleteDietaryPreferences) } diff --git a/backend/internal/handlers/users/service.go b/backend/internal/handlers/users/service.go index 02956e50..ad2d5fbc 100644 --- a/backend/internal/handlers/users/service.go +++ b/backend/internal/handlers/users/service.go @@ -432,3 +432,45 @@ func (s *Service) GetDietaryPreferences(userId string) ([]string, error) { return dietaryRestrictions, nil } + +func (s *Service) PostDietaryPreferences(userId string, preference string) error { + ctx := context.Background() + userObjID, err := primitive.ObjectIDFromHex(userId) + if err != nil { + badReq := xerr.BadRequest(err) + return &badReq + } + + update := bson.M{ + "$push": bson.M{"preferences": preference}, + } + + // Update the user's dietary preferences in the database + _, err = s.users.UpdateOne(ctx, bson.M{"_id": userObjID}, update) + if err != nil { + return err + } + + return nil +} + +func (s *Service) DeleteDietaryPreferences(userId string, preference string) error { + ctx := context.Background() + userObjID, err := primitive.ObjectIDFromHex(userId) + if err != nil { + badReq := xerr.BadRequest(err) + return &badReq + } + + delete := bson.M{ + "$pull": bson.M{"preferences": preference}, + } + + // Update the user's dietary preferences in the database + _, err = s.users.UpdateOne(ctx, bson.M{"_id": userObjID}, delete) + if err != nil { + return err + } + + return nil +} \ No newline at end of file diff --git a/backend/internal/handlers/users/types.go b/backend/internal/handlers/users/types.go index 2decf2ff..e02935c8 100644 --- a/backend/internal/handlers/users/types.go +++ b/backend/internal/handlers/users/types.go @@ -53,3 +53,7 @@ type ReviewQuery struct { UserId string `query:"userId" validate:"required"` ItemId string `params:"id" validate:"required"` } + +type PostDietaryPreferencesQuery struct { + Preference string `json:"preference"` +} diff --git a/backend/internal/handlers/users/user_connections.go b/backend/internal/handlers/users/user_connections.go index 5c024fbc..9dbb115b 100644 --- a/backend/internal/handlers/users/user_connections.go +++ b/backend/internal/handlers/users/user_connections.go @@ -182,7 +182,7 @@ func (h *Handler) UnfollowUser(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } -// GetDietaryPreferences retrieves the dietary preferences of a user +// GetDietaryPreferences retrieves the dietary preferences of a user func (h *Handler) GetDietaryPreferences(c *fiber.Ctx) error { userId := c.Params("id") @@ -196,3 +196,33 @@ func (h *Handler) GetDietaryPreferences(c *fiber.Ctx) error { return c.JSON(preferences) } + +func (h *Handler) PostDietaryPreferences(c *fiber.Ctx) error { + userId := c.Params("id") + preference := c.Query("preference") + + err := h.service.PostDietaryPreferences(userId, preference) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return c.Status(fiber.StatusNotFound).JSON(xerr.NotFound("User", "id", "specified")) + } + return err + } + + return c.SendStatus(fiber.StatusCreated) +} + +func (h *Handler) DeleteDietaryPreferences(c *fiber.Ctx) error { + userId := c.Params("id") + preference := c.Query("preference") + + err := h.service.DeleteDietaryPreferences(userId, preference) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return c.Status(fiber.StatusNotFound).JSON(xerr.NotFound("User", "id", "specified")) + } + return err + } + + return c.SendStatus(fiber.StatusCreated) +} diff --git a/frontend/app/(tabs)/profile/settings.tsx b/frontend/app/(tabs)/profile/settings.tsx index 31732e5b..73041b4f 100644 --- a/frontend/app/(tabs)/profile/settings.tsx +++ b/frontend/app/(tabs)/profile/settings.tsx @@ -10,79 +10,115 @@ import SettingsMenuItem from "@/components/profile/settings/SettingsMenuItem"; import { TSettingsData } from "@/types/settingsData"; import useAuthStore from "@/auth/store"; import { Button } from "@/components/Button"; +import axios from 'axios'; export default function SettingsScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); - const { email } = useAuthStore(); + const { email, userId } = useAuthStore(); const { logout } = useAuthStore(); - const dietaryOptions = [ - "vegetarian", - "vegan", - "nutFree", - "shellfishAllergy", - "glutenFree", - "dairyFree", - "kosher", - "halal", - "pescatarian", - "keto", - "diabetic", - "soyFree", - "porkFree", - "beefFree", - ]; + const dietaryOptions: Record = { + "vegetarian": "Vegetarian", + "vegan": "Vegan", + "nutFree": "Nut-free", + "shellfishAllergy": "Shellfish Allergy", + "glutenFree": "Gluten-free", + "dairyFree": "Dairy-free", + "kosher": "Kosher", + "halal": "Halal", + "pescatarian": "Pescatarian", + "keto": "Keto", + "diabetic": "Diabetic", + "soyFree": "Soy-free", + "porkFree": "Pork-free", + "beefFree": "Beef-free", + }; + + const [dietaryRestrictions, setDietaryRestrictions] = useState([]); const [settings, setSettings] = useState>( - Object.fromEntries(dietaryOptions.map((option) => [option, false])), + Object.fromEntries(Object.values(dietaryOptions).map((option) => [option, false])), ); - // useEffect(() => { - // const fetchPreferences = async () => { - // try { - // const userData = await fetchUserProfile(); // Fetch user profile using the context function - - // if (!userData) return; // Ensure userData exists before proceeding - - // const userRestrictions: string[] = userData.preferences || []; + useEffect(() => { + console.log("fetched restrictions!"); + const fetchDietaryRestrictions = async () => { + try { + const response = await axios.get( + `https://externally-exotic-orca.ngrok-free.app/api/v1/settings/${userId}/dietaryPreferences` + ); + setDietaryRestrictions(response.data); + } + catch (err) { + console.log('Failed to fetch dietary restrictions'); + console.error(err); + } + }; + + fetchDietaryRestrictions(); + console.log(dietaryRestrictions); + }, [userId]); - // // Convert restrictions array to object { vegetarian: true, glutenFree: true, keto: true, ... } - // const updatedSettings = dietaryOptions.reduce((acc, option) => { - // acc[option] = userRestrictions.includes(option); - // return acc; - // }, {} as Record); - - // setSettings(updatedSettings); - // } catch (error) { - // console.error("Error fetching user preferences:", error); - // } - // }; - - // fetchPreferences(); - // }, [fetchUserProfile]); + useEffect(() => { + console.log("reloaded!"); + console.log(dietaryRestrictions); + setSettings( + Object.fromEntries( + Object.keys(dietaryOptions).map((option) => [ + option, + dietaryRestrictions.includes(dietaryOptions[option]), + ]) + ) + ); + }, [dietaryRestrictions]) const updateSetting = (key: string, value: boolean) => { - setSettings((prevSettings) => ({ - ...prevSettings, - [`${key}`]: value, - })); + if (value == true) { + setDietaryRestrictions((prevRestrictions) => [ + ...prevRestrictions, + dietaryOptions[key], + ]); + handleAddDietaryPreference(key); + } else { + setDietaryRestrictions((prevRestrictions) => + prevRestrictions.filter((item) => item !== dietaryOptions[key]) + ); + handleRemoveDietaryPreference(key); + } + }; + + const handleAddDietaryPreference = async (preference:string) => { + try { + const response = await axios.post( + `https://externally-exotic-orca.ngrok-free.app/api/v1/settings/${userId}/dietaryPreferences?preference=${dietaryOptions[preference]}` + ); + if (response.status === 201) { + console.log('Preference added successfully:', preference); + } + } + catch (err) { + console.log('Failed to add dietary preference'); + console.error(err); + } }; - // const updateSetting = async (key: string, value: boolean) => { - // try { - // const updatedSettings = { ...settings, [key]: value }; - // setSettings(updatedSettings); - // // Send updated preferences to the server - // await axios.put(`${process.env.API_BASE_URL}/api/v1/user/${userId}`, { - // preferences: { restrictions: Object.keys(updatedSettings).filter((k) => updatedSettings[k]) }, - // }); - // } catch (error) { - // console.error("Error saving setting:", error); - // } - // }; + const handleRemoveDietaryPreference = async (preference:string) => { + try { + const response = await axios.delete( + `https://externally-exotic-orca.ngrok-free.app/api/v1/settings/${userId}/dietaryPreferences?preference=${dietaryOptions[preference]}` + ); + if (response.status === 201) { + console.log('Preference deleted successfully:', preference); + } + } + catch (err) { + console.log('Failed to delete dietary preference'); + console.error(err); + } + }; const settingsData: TSettingsData = { credentials: [ From 90c32c58698796cad41a1bd40d3a4e8a179a6b5d Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Sat, 29 Mar 2025 19:03:00 -0400 Subject: [PATCH 15/26] can view your friends! --- backend/internal/handlers/users/routes.go | 1 + backend/internal/handlers/users/service.go | 111 +++++++++++++++--- backend/internal/handlers/users/types.go | 5 + .../handlers/users/user_connections.go | 25 +++- .../profile/{followers.tsx => friends.tsx} | 73 ++++++------ frontend/app/(tabs)/profile/settings.tsx | 18 +-- frontend/types/follower.ts | 2 +- 7 files changed, 174 insertions(+), 61 deletions(-) rename frontend/app/(tabs)/profile/{followers.tsx => friends.tsx} (76%) diff --git a/backend/internal/handlers/users/routes.go b/backend/internal/handlers/users/routes.go index a91f4d23..81e10b5a 100644 --- a/backend/internal/handlers/users/routes.go +++ b/backend/internal/handlers/users/routes.go @@ -17,6 +17,7 @@ func Routes(app *fiber.App, collections map[string]*mongo.Collection) { user.Get("/:id", handler.GetUserById) user.Get("/followers", handler.GetFollowers) + user.Get("/:id/following", handler.GetFollowing) user.Post("/follow", handler.FollowUser) user.Delete("/follow", handler.UnfollowUser) diff --git a/backend/internal/handlers/users/service.go b/backend/internal/handlers/users/service.go index ad2d5fbc..49696791 100644 --- a/backend/internal/handlers/users/service.go +++ b/backend/internal/handlers/users/service.go @@ -3,6 +3,7 @@ package users import ( "context" "errors" + "fmt" "github.com/GenerateNU/platemate/internal/handlers/menu_items" "github.com/GenerateNU/platemate/internal/handlers/review" @@ -134,6 +135,86 @@ func (s *Service) GetUserFollowers(userId string, page, limit int) ([]UserRespon FollowersCount: follower.FollowersCount, FollowingCount: follower.FollowingCount, Reviews: reviews, + Preferences: follower.Preferences, + } + } + + return response, nil +} + +func (s *Service) GetUserFollowing(userId string, page, limit int) ([]UserResponse, error) { + ctx := context.Background() + fmt.Println((userId)) + userObjID, err := primitive.ObjectIDFromHex(userId) + if err != nil { + badReq := xerr.BadRequest(err) + return nil, &badReq + } + + // Find the user and get their followers + var user User + err = s.users.FindOne(ctx, bson.M{"_id": userObjID}).Decode(&user) + if err != nil { + return nil, err + } + + // Calculate total pages and adjust page number if out of bounds + totalFollowing := len(user.Following) + totalPages := (totalFollowing + limit - 1) / limit // Ceiling division + + if totalPages == 0 { + return []UserResponse{}, nil + } + + // Adjust page to be within bounds + if page > totalPages { + page = totalPages + } + if page < 1 { + page = 1 + } + + // Calculate pagination bounds + skip := (page - 1) * limit + end := skip + limit + if end > totalFollowing { + end = totalFollowing + } + + // Get the slice of follower IDs for this page, could be an issue if the value is 0 + pageFollowers := user.Following[skip:end] + + // Fetch the actual user documents for these followers + cursor, err := s.users.Find(ctx, bson.M{ + "_id": bson.M{"$in": pageFollowers}, + }) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var following []User + if err = cursor.All(ctx, &following); err != nil { + return nil, err + } + + // Convert to response format + response := make([]UserResponse, len(following)) + for i, followingUser := range following { + reviews := make([]string, len(followingUser.Reviews)) + for j, reviewID := range followingUser.Reviews { + reviews[j] = reviewID.Hex() + } + + response[i] = UserResponse{ + ID: followingUser.ID.Hex(), + Name: followingUser.Name, + Username: followingUser.Username, + ProfilePicture: followingUser.ProfilePicture, + FollowersCount: followingUser.FollowersCount, + FollowingCount: followingUser.FollowingCount, + Reviews: reviews, + Preferences: followingUser.Preferences, } } @@ -442,14 +523,14 @@ func (s *Service) PostDietaryPreferences(userId string, preference string) error } update := bson.M{ - "$push": bson.M{"preferences": preference}, - } + "$push": bson.M{"preferences": preference}, + } - // Update the user's dietary preferences in the database - _, err = s.users.UpdateOne(ctx, bson.M{"_id": userObjID}, update) - if err != nil { - return err - } + // Update the user's dietary preferences in the database + _, err = s.users.UpdateOne(ctx, bson.M{"_id": userObjID}, update) + if err != nil { + return err + } return nil } @@ -463,14 +544,14 @@ func (s *Service) DeleteDietaryPreferences(userId string, preference string) err } delete := bson.M{ - "$pull": bson.M{"preferences": preference}, - } + "$pull": bson.M{"preferences": preference}, + } - // Update the user's dietary preferences in the database - _, err = s.users.UpdateOne(ctx, bson.M{"_id": userObjID}, delete) - if err != nil { - return err - } + // Update the user's dietary preferences in the database + _, err = s.users.UpdateOne(ctx, bson.M{"_id": userObjID}, delete) + if err != nil { + return err + } return nil -} \ No newline at end of file +} diff --git a/backend/internal/handlers/users/types.go b/backend/internal/handlers/users/types.go index e02935c8..abcc1f53 100644 --- a/backend/internal/handlers/users/types.go +++ b/backend/internal/handlers/users/types.go @@ -49,6 +49,11 @@ type GetFollowersQuery struct { UserId string `query:"userId" validate:"required"` } +type GetFollowingQuery struct { + PaginationQuery + UserId string `query:"userId" validate:"required"` +} + type ReviewQuery struct { UserId string `query:"userId" validate:"required"` ItemId string `params:"id" validate:"required"` diff --git a/backend/internal/handlers/users/user_connections.go b/backend/internal/handlers/users/user_connections.go index 9dbb115b..f2913248 100644 --- a/backend/internal/handlers/users/user_connections.go +++ b/backend/internal/handlers/users/user_connections.go @@ -2,7 +2,6 @@ package users import ( "errors" - "github.com/GenerateNU/platemate/internal/xerr" "github.com/GenerateNU/platemate/internal/xvalidator" "github.com/gofiber/fiber/v2" @@ -69,6 +68,30 @@ func (h *Handler) GetFollowers(c *fiber.Ctx) error { return c.JSON(followers) } +// GetFollowing returns a paginated list of who the user is following +func (h *Handler) GetFollowing(c *fiber.Ctx) error { + var query GetFollowingQuery + userId := c.Params("id") + + // Set defaults if not provided + if query.Page < 1 { + query.Page = 1 + } + if query.Limit < 1 { + query.Limit = 20 + } + + followers, err := h.service.GetUserFollowing(userId, query.Page, query.Limit) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return c.Status(fiber.StatusNotFound).JSON(xerr.NotFound("User", "id", query.UserId)) + } + return err + } + + return c.JSON(followers) +} + // GetFollowingReviewsForItem gets reviews for a menu item from users that the current user follows func (h *Handler) GetFollowingReviewsForItem(c *fiber.Ctx) error { diff --git a/frontend/app/(tabs)/profile/followers.tsx b/frontend/app/(tabs)/profile/friends.tsx similarity index 76% rename from frontend/app/(tabs)/profile/followers.tsx rename to frontend/app/(tabs)/profile/friends.tsx index abd80918..f2aea07d 100644 --- a/frontend/app/(tabs)/profile/followers.tsx +++ b/frontend/app/(tabs)/profile/friends.tsx @@ -4,58 +4,61 @@ import React, { useState, useEffect, useCallback } from "react"; import { View, Text, StyleSheet, FlatList, TextInput, ActivityIndicator } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import FollowerItem from "@/components/profile/followers/FollowerItem"; -import { TFollower } from "@/types/follower"; +import { TFriend } from "@/types/follower"; +import useAuthStore from "@/auth/store"; -export default function FollowersScreen() { +export default function FriendsScreen() { const insets = useSafeAreaInsets(); const [searchQuery, setSearchQuery] = useState(""); - const [followers, setFollowers] = useState([]); + const [friends, setFriends] = useState([]); const [loading, setLoading] = useState(false); const [refreshing, setRefreshing] = useState(false); const [page, setPage] = useState(1); const [isLoadingMore, setIsLoadingMore] = useState(false); const [hasMoreData, setHasMoreData] = useState(true); + const { userId } = useAuthStore(); useEffect(() => { - fetchRandomUsers(); + fetchFriends(); }, []); - const fetchRandomUsers = async (pageNum = 1, isRefresh = false) => { - if (pageNum === 1) { + const fetchFriends = async (pageNum = 1, isRefresh = false) => { + if (friends.length == 0) { setLoading(true); - } else { - setIsLoadingMore(true); } try { - const response = await fetch(`https://randomuser.me/api/?results=10&page=${pageNum}`); + const response = await fetch(`https://externally-exotic-orca.ngrok-free.app/api/v1/user/${userId}/following`); const data = await response.json(); - if (data.results && data.results.length > 0) { - const formattedUsers = data.results.map((user) => ({ - id: user.login.uuid, - name: `${user.name.first} ${user.name.last}`, - username: `@${user.login.username}`, - avatar: user.picture.medium, + if (data && data.length > 0) { + const formattedUsers = data.map((user) => ({ + id: user.id, + name: `${user.name}`, + username: `@${user.username}`, + avatar: user.profile_picture, })); - if (isRefresh) { - setFollowers(formattedUsers); - setPage(2); - } else if (pageNum === 1) { - setFollowers(formattedUsers); - setPage(2); - } else { - setFollowers((prevFollowers) => [...prevFollowers, ...formattedUsers]); - setPage(pageNum + 1); - } - - setHasMoreData(pageNum < 15); + setFriends(formattedUsers); + + // dont think this stuff is needed anymore but left it in case + // if (isRefresh) { + // setFollowers(formattedUsers); + // setPage(2); + // } else if (pageNum === 1) { + // setFollowers(formattedUsers); + // setPage(2); + // } else { + // setFollowers((prevFollowers) => [...prevFollowers, ...formattedUsers]); + // setPage(pageNum + 1); + // } + + // setHasMoreData(pageNum < 15); } else { - setHasMoreData(false); + // setHasMoreData(false); } } catch (error) { - console.error("Error fetching random users:", error); + console.error("Error fetching users:", error); } finally { setLoading(false); setIsLoadingMore(false); @@ -67,23 +70,23 @@ export default function FollowersScreen() { if (!refreshing) { setRefreshing(true); setHasMoreData(true); - fetchRandomUsers(1, true); + fetchFriends(1, true); } }, [refreshing]); const handleLoadMore = useCallback(() => { if (!isLoadingMore && hasMoreData && !loading && !refreshing) { - fetchRandomUsers(page); + fetchFriends(page); } }, [isLoadingMore, hasMoreData, loading, refreshing, page]); - const filteredFollowers = followers.filter( + const filteredFollowers = friends.filter( (follower) => follower.name.toLowerCase().includes(searchQuery.toLowerCase()) || follower.username.toLowerCase().includes(searchQuery.toLowerCase()), ); - const renderFollower = ({ item }: { item: TFollower }) => ; + const renderFollower = ({ item }: { item: TFriend }) => ; const renderFooter = () => { if (!isLoadingMore) return null; @@ -97,7 +100,7 @@ export default function FollowersScreen() { }; const renderNoMoreData = () => { - if (followers.length > 0 && !hasMoreData && !isLoadingMore) { + if (friends.length > 0 && !hasMoreData && !isLoadingMore) { return ( No more followers to load. @@ -138,7 +141,7 @@ export default function FollowersScreen() { } ListHeaderComponent={ - {followers.length} {followers.length === 1 ? "Friend" : "Friends"} + {friends.length} {friends.length === 1 ? "Friend" : "Friends"} } ListFooterComponent={ diff --git a/frontend/app/(tabs)/profile/settings.tsx b/frontend/app/(tabs)/profile/settings.tsx index 73041b4f..3b129bf3 100644 --- a/frontend/app/(tabs)/profile/settings.tsx +++ b/frontend/app/(tabs)/profile/settings.tsx @@ -36,7 +36,7 @@ export default function SettingsScreen() { "beefFree": "Beef-free", }; - const [dietaryRestrictions, setDietaryRestrictions] = useState([]); + const [dietaryPreferences, setDietaryPreferences] = useState([]); const [settings, setSettings] = useState>( Object.fromEntries(Object.values(dietaryOptions).map((option) => [option, false])), @@ -49,7 +49,7 @@ export default function SettingsScreen() { const response = await axios.get( `https://externally-exotic-orca.ngrok-free.app/api/v1/settings/${userId}/dietaryPreferences` ); - setDietaryRestrictions(response.data); + setDietaryPreferences(response.data); } catch (err) { console.log('Failed to fetch dietary restrictions'); @@ -58,31 +58,31 @@ export default function SettingsScreen() { }; fetchDietaryRestrictions(); - console.log(dietaryRestrictions); + console.log(dietaryPreferences); }, [userId]); useEffect(() => { console.log("reloaded!"); - console.log(dietaryRestrictions); + console.log(dietaryPreferences); setSettings( Object.fromEntries( Object.keys(dietaryOptions).map((option) => [ option, - dietaryRestrictions.includes(dietaryOptions[option]), + dietaryPreferences.includes(dietaryOptions[option]), ]) ) ); - }, [dietaryRestrictions]) + }, [dietaryPreferences]) const updateSetting = (key: string, value: boolean) => { if (value == true) { - setDietaryRestrictions((prevRestrictions) => [ + setDietaryPreferences((prevRestrictions) => [ ...prevRestrictions, dietaryOptions[key], ]); handleAddDietaryPreference(key); } else { - setDietaryRestrictions((prevRestrictions) => + setDietaryPreferences((prevRestrictions) => prevRestrictions.filter((item) => item !== dietaryOptions[key]) ); handleRemoveDietaryPreference(key); @@ -148,7 +148,7 @@ export default function SettingsScreen() { account: [ { label: "View Friends", - onPress: () => router.push("/(tabs)/profile/followers"), + onPress: () => router.push("/(tabs)/profile/friends"), showChevron: true, }, ], diff --git a/frontend/types/follower.ts b/frontend/types/follower.ts index bfeabd59..1aa7aaf5 100644 --- a/frontend/types/follower.ts +++ b/frontend/types/follower.ts @@ -1,4 +1,4 @@ -export type TFollower = { +export type TFriend = { id: string; name: string; username: string; From 9e3e3ee4ee46bd11df7d5f822859f269c9948f1d Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Sat, 29 Mar 2025 19:35:18 -0400 Subject: [PATCH 16/26] loaded friends --- frontend/app/(tabs)/profile/friends.tsx | 4 ++-- frontend/app/(tabs)/profile/settings.tsx | 6 +++++- frontend/components/profile/ProfileMetrics.tsx | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/app/(tabs)/profile/friends.tsx b/frontend/app/(tabs)/profile/friends.tsx index f2aea07d..6f530a53 100644 --- a/frontend/app/(tabs)/profile/friends.tsx +++ b/frontend/app/(tabs)/profile/friends.tsx @@ -103,7 +103,7 @@ export default function FriendsScreen() { if (friends.length > 0 && !hasMoreData && !isLoadingMore) { return ( - No more followers to load. + No more friends to load. ); } @@ -136,7 +136,7 @@ export default function FriendsScreen() { showsVerticalScrollIndicator={false} ListEmptyComponent={ - No followers found. + No friends found. } ListHeaderComponent={ diff --git a/frontend/app/(tabs)/profile/settings.tsx b/frontend/app/(tabs)/profile/settings.tsx index 3b129bf3..7ef912ac 100644 --- a/frontend/app/(tabs)/profile/settings.tsx +++ b/frontend/app/(tabs)/profile/settings.tsx @@ -17,6 +17,9 @@ export default function SettingsScreen() { const router = useRouter(); const { email, userId } = useAuthStore(); + // const userIdStr = JSON.stringify(userId); + console.log(userId); + const { logout } = useAuthStore(); const dietaryOptions: Record = { @@ -148,7 +151,8 @@ export default function SettingsScreen() { account: [ { label: "View Friends", - onPress: () => router.push("/(tabs)/profile/friends"), + //confused... + onPress: () => router.push(`/profile/friends?userId=${userId}`), showChevron: true, }, ], diff --git a/frontend/components/profile/ProfileMetrics.tsx b/frontend/components/profile/ProfileMetrics.tsx index a1230783..cb39f3e9 100644 --- a/frontend/components/profile/ProfileMetrics.tsx +++ b/frontend/components/profile/ProfileMetrics.tsx @@ -20,7 +20,7 @@ const ProfileMetrics = (props: ProfileMetricProps) => { - { router.push("/(tabs)/profile/followers") }}> + { router.push("/(tabs)/profile/friends") }}> {props.numFriends} friends From 1b93175bdbbc70657a876646557363d4a0352afa Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Sat, 29 Mar 2025 19:40:29 -0400 Subject: [PATCH 17/26] linted and fixed formatting: --- backend/internal/handlers/users/types.go | 2 +- frontend/app/(tabs)/profile/friends.tsx | 6 +- frontend/app/(tabs)/profile/settings.tsx | 124 +++++++++--------- .../components/profile/ProfileMetrics.tsx | 5 +- 4 files changed, 70 insertions(+), 67 deletions(-) diff --git a/backend/internal/handlers/users/types.go b/backend/internal/handlers/users/types.go index abcc1f53..76a64436 100644 --- a/backend/internal/handlers/users/types.go +++ b/backend/internal/handlers/users/types.go @@ -15,7 +15,7 @@ type User struct { FollowersCount int `bson:"followersCount"` ProfilePicture string `bson:"profile_picture,omitempty"` Name string `bson:"name,omitempty"` - Preferences []string `bson:"preferences,omitempty"` + Preferences []string `bson:"preferences,omitempty"` } type UserResponse struct { diff --git a/frontend/app/(tabs)/profile/friends.tsx b/frontend/app/(tabs)/profile/friends.tsx index 6f530a53..8550f831 100644 --- a/frontend/app/(tabs)/profile/friends.tsx +++ b/frontend/app/(tabs)/profile/friends.tsx @@ -28,7 +28,9 @@ export default function FriendsScreen() { } try { - const response = await fetch(`https://externally-exotic-orca.ngrok-free.app/api/v1/user/${userId}/following`); + const response = await fetch( + `https://externally-exotic-orca.ngrok-free.app/api/v1/user/${userId}/following`, + ); const data = await response.json(); if (data && data.length > 0) { @@ -41,7 +43,7 @@ export default function FriendsScreen() { setFriends(formattedUsers); - // dont think this stuff is needed anymore but left it in case + // dont think this stuff is needed anymore but left it in case // if (isRefresh) { // setFollowers(formattedUsers); // setPage(2); diff --git a/frontend/app/(tabs)/profile/settings.tsx b/frontend/app/(tabs)/profile/settings.tsx index 7ef912ac..da621451 100644 --- a/frontend/app/(tabs)/profile/settings.tsx +++ b/frontend/app/(tabs)/profile/settings.tsx @@ -10,7 +10,7 @@ import SettingsMenuItem from "@/components/profile/settings/SettingsMenuItem"; import { TSettingsData } from "@/types/settingsData"; import useAuthStore from "@/auth/store"; import { Button } from "@/components/Button"; -import axios from 'axios'; +import axios from "axios"; export default function SettingsScreen() { const insets = useSafeAreaInsets(); @@ -23,20 +23,20 @@ export default function SettingsScreen() { const { logout } = useAuthStore(); const dietaryOptions: Record = { - "vegetarian": "Vegetarian", - "vegan": "Vegan", - "nutFree": "Nut-free", - "shellfishAllergy": "Shellfish Allergy", - "glutenFree": "Gluten-free", - "dairyFree": "Dairy-free", - "kosher": "Kosher", - "halal": "Halal", - "pescatarian": "Pescatarian", - "keto": "Keto", - "diabetic": "Diabetic", - "soyFree": "Soy-free", - "porkFree": "Pork-free", - "beefFree": "Beef-free", + vegetarian: "Vegetarian", + vegan: "Vegan", + nutFree: "Nut-free", + shellfishAllergy: "Shellfish Allergy", + glutenFree: "Gluten-free", + dairyFree: "Dairy-free", + kosher: "Kosher", + halal: "Halal", + pescatarian: "Pescatarian", + keto: "Keto", + diabetic: "Diabetic", + soyFree: "Soy-free", + porkFree: "Pork-free", + beefFree: "Beef-free", }; const [dietaryPreferences, setDietaryPreferences] = useState([]); @@ -48,80 +48,73 @@ export default function SettingsScreen() { useEffect(() => { console.log("fetched restrictions!"); const fetchDietaryRestrictions = async () => { - try { - const response = await axios.get( - `https://externally-exotic-orca.ngrok-free.app/api/v1/settings/${userId}/dietaryPreferences` - ); - setDietaryPreferences(response.data); - } - catch (err) { - console.log('Failed to fetch dietary restrictions'); - console.error(err); - } + try { + const response = await axios.get( + `https://externally-exotic-orca.ngrok-free.app/api/v1/settings/${userId}/dietaryPreferences`, + ); + setDietaryPreferences(response.data); + } catch (err) { + console.log("Failed to fetch dietary restrictions"); + console.error(err); + } }; - + fetchDietaryRestrictions(); console.log(dietaryPreferences); - }, [userId]); + }, [userId]); - useEffect(() => { + useEffect(() => { console.log("reloaded!"); console.log(dietaryPreferences); setSettings( Object.fromEntries( - Object.keys(dietaryOptions).map((option) => [ - option, - dietaryPreferences.includes(dietaryOptions[option]), - ]) - ) + Object.keys(dietaryOptions).map((option) => [ + option, + dietaryPreferences.includes(dietaryOptions[option]), + ]), + ), ); - }, [dietaryPreferences]) + }, [dietaryPreferences]); const updateSetting = (key: string, value: boolean) => { if (value == true) { - setDietaryPreferences((prevRestrictions) => [ - ...prevRestrictions, - dietaryOptions[key], - ]); + setDietaryPreferences((prevRestrictions) => [...prevRestrictions, dietaryOptions[key]]); handleAddDietaryPreference(key); } else { setDietaryPreferences((prevRestrictions) => - prevRestrictions.filter((item) => item !== dietaryOptions[key]) + prevRestrictions.filter((item) => item !== dietaryOptions[key]), ); handleRemoveDietaryPreference(key); } }; - const handleAddDietaryPreference = async (preference:string) => { - try { + const handleAddDietaryPreference = async (preference: string) => { + try { const response = await axios.post( - `https://externally-exotic-orca.ngrok-free.app/api/v1/settings/${userId}/dietaryPreferences?preference=${dietaryOptions[preference]}` + `https://externally-exotic-orca.ngrok-free.app/api/v1/settings/${userId}/dietaryPreferences?preference=${dietaryOptions[preference]}`, ); if (response.status === 201) { - console.log('Preference added successfully:', preference); + console.log("Preference added successfully:", preference); } - } - catch (err) { - console.log('Failed to add dietary preference'); - console.error(err); - } + } catch (err) { + console.log("Failed to add dietary preference"); + console.error(err); + } }; - - const handleRemoveDietaryPreference = async (preference:string) => { + const handleRemoveDietaryPreference = async (preference: string) => { try { - const response = await axios.delete( - `https://externally-exotic-orca.ngrok-free.app/api/v1/settings/${userId}/dietaryPreferences?preference=${dietaryOptions[preference]}` - ); - if (response.status === 201) { - console.log('Preference deleted successfully:', preference); - } - } - catch (err) { - console.log('Failed to delete dietary preference'); - console.error(err); + const response = await axios.delete( + `https://externally-exotic-orca.ngrok-free.app/api/v1/settings/${userId}/dietaryPreferences?preference=${dietaryOptions[preference]}`, + ); + if (response.status === 201) { + console.log("Preference deleted successfully:", preference); + } + } catch (err) { + console.log("Failed to delete dietary preference"); + console.error(err); } - }; + }; const settingsData: TSettingsData = { credentials: [ @@ -165,7 +158,7 @@ export default function SettingsScreen() { const handleLogOut = () => { logout(); router.replace("/(onboarding)"); - } + }; return ( @@ -232,7 +225,12 @@ export default function SettingsScreen() { ))} - + diff --git a/frontend/components/profile/ProfileMetrics.tsx b/frontend/components/profile/ProfileMetrics.tsx index cb39f3e9..95fae35f 100644 --- a/frontend/components/profile/ProfileMetrics.tsx +++ b/frontend/components/profile/ProfileMetrics.tsx @@ -20,7 +20,10 @@ const ProfileMetrics = (props: ProfileMetricProps) => { - { router.push("/(tabs)/profile/friends") }}> + { + router.push("/(tabs)/profile/friends"); + }}> {props.numFriends} friends From 981208f21f6def674c51d4af8bee67bcb73860f3 Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Sat, 5 Apr 2025 14:35:45 -0400 Subject: [PATCH 18/26] connected user's friends and reviews to the backend --- frontend/app/(tabs)/profile/profile.tsx | 34 ++++-- .../app/{friend.tsx => friend/[userId].tsx} | 111 +++++++++++++----- .../profile/followers/FollowerItem.tsx | 6 +- frontend/components/review/ReviewPreview.tsx | 15 ++- frontend/context/user-context.tsx | 2 +- frontend/types/review.ts | 14 +++ 6 files changed, 136 insertions(+), 46 deletions(-) rename frontend/app/{friend.tsx => friend/[userId].tsx} (54%) create mode 100644 frontend/types/review.ts diff --git a/frontend/app/(tabs)/profile/profile.tsx b/frontend/app/(tabs)/profile/profile.tsx index 2c8b829f..6431a962 100644 --- a/frontend/app/(tabs)/profile/profile.tsx +++ b/frontend/app/(tabs)/profile/profile.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useUser } from "@/context/user-context"; import { ThemedView } from "@/components/themed/ThemedView"; import { ActivityIndicator, Dimensions, ScrollView, StatusBar, StyleSheet, TouchableOpacity } from "react-native"; @@ -13,12 +13,14 @@ import { router } from "expo-router"; import EditProfileSheet from "@/components/profile/EditProfileSheet"; import ReviewPreview from "@/components/review/ReviewPreview"; import { SearchBoxFilter } from "@/components/SearchBoxFilter"; +import type { Review } from '@/types/review'; const { width } = Dimensions.get("window"); const ProfileScreen = () => { const { user, isLoading, error, fetchUserProfile } = useUser(); const [searchText, setSearchText] = React.useState(""); + const [userReviews, setUserReviews] = useState([]); const editProfileRef = useRef<{ open: () => void; close: () => void }>(null); @@ -27,6 +29,20 @@ const ProfileScreen = () => { console.log("User data not available, fetching..."); fetchUserProfile().then(() => {}); } + const fetchReviews = async () => { + if (!user?.id) return ; + + try { + const reviewsRes = await fetch( + `https://externally-exotic-orca.ngrok-free.app/api/v1/review/user/${user.id}`); + const reviewData = await reviewsRes.json(); + console.log(reviewData); + setUserReviews(reviewData); + } catch (err) { + console.error("Failed to fetch user by ID", err); + } + }; + fetchReviews(); }, [user, isLoading]); if (isLoading) { @@ -76,12 +92,16 @@ const ProfileScreen = () => { value={searchText} onChangeText={(text) => setSearchText(text)} /> - + {userReviews.map((review) => ( + + + ))} diff --git a/frontend/app/friend.tsx b/frontend/app/friend/[userId].tsx similarity index 54% rename from frontend/app/friend.tsx rename to frontend/app/friend/[userId].tsx index e43f10e7..e9c0d5b1 100644 --- a/frontend/app/friend.tsx +++ b/frontend/app/friend/[userId].tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useUser } from "@/context/user-context"; import { ThemedView } from "@/components/themed/ThemedView"; import { ActivityIndicator, Dimensions, ScrollView, StatusBar, StyleSheet, TouchableOpacity } from "react-native"; @@ -12,21 +12,68 @@ import ReviewPreview from "@/components/review/ReviewPreview"; import { SearchBoxFilter } from "@/components/SearchBoxFilter"; import EditFriendSheet from "@/components/profile/followers/FriendProfileOptions"; import { FollowButton } from "@/components/profile/followers/FollowButton"; +import { useLocalSearchParams } from 'expo-router'; +import type { User } from '@/context/user-context'; +import type { Review } from '@/types/review'; const { width } = Dimensions.get("window"); const ProfileScreen = () => { - const { user, isLoading, error, fetchUserProfile } = useUser(); - const [searchText, setSearchText] = React.useState(""); - + console.log("hi"); + const {userId} = useLocalSearchParams(); + console.log(userId); + const [searchText, setSearchText] = useState(""); const editFriend = useRef<{ open: () => void; close: () => void }>(null); - + + const [user, setUser] = useState({ + id: "", + email: "", + name: "", + profile_picture: "", + username: "", + followersCount: 0, + followingCount: 0, + preferences: [] + }); //initialziing the user to an empty user + const [userReviews, setUserReviews] = useState([]); + const [isLoading, setLoading] = useState(true); + useEffect(() => { - if (!user && !isLoading) { - console.log("User data not available, fetching..."); - fetchUserProfile().then(() => {}); + const fetchUserAndReviews = async () => { + if (!userId) return ; + + setLoading(true); + try { + const userRes = await fetch( + `https://externally-exotic-orca.ngrok-free.app/api/v1/user/${userId}`); + const userData = await userRes.json(); + console.log(userData); + const newUser: User = { + id: userData.id, + email: userData.email, + name: userData.name, + profile_picture: userData.profile_picture, + username: userData.username, + followersCount: userData.followersCount, + followingCount: userData.followingCount, + preferences: userData.preferences, + }; + setUser(newUser); + + const reviewsRes = await fetch( + `https://externally-exotic-orca.ngrok-free.app/api/v1/review/user/${userId}`); + const reviewData = await reviewsRes.json(); + console.log(reviewData); + setUserReviews(reviewData); + } catch (err) { + console.error("Failed to fetch user by ID", err); + } finally { + setLoading(false); } - }, [user, isLoading]); + }; + + fetchUserAndReviews(); + }, [userId]); if (isLoading) { return ( @@ -36,14 +83,14 @@ const ProfileScreen = () => { ); } - - if (!user || error) { - return ( - - An error occurred. - - ); - } + // for now!!! + // if (!user || error) { + // return ( + // + // An error occurred. + // + // ); + // } return ( @@ -58,35 +105,39 @@ const ProfileScreen = () => { style={styles.ellipseButton} onPress={() => { console.log("Button Pressed!"); - console.log("editFriend Ref:", editFriend.current); - editFriend.current?.open(); }}> - - - + {/* inserted a default profile picture because profile_picture is string | undefined */} + + + - Ben's Food Journal + {user.name}'s Food Journal console.log("submit")} value={searchText} onChangeText={(text) => setSearchText(text)} /> - + {userReviews.map((review) => ( + + + ))} diff --git a/frontend/components/profile/followers/FollowerItem.tsx b/frontend/components/profile/followers/FollowerItem.tsx index 404ed776..f8764be1 100644 --- a/frontend/components/profile/followers/FollowerItem.tsx +++ b/frontend/components/profile/followers/FollowerItem.tsx @@ -1,8 +1,10 @@ import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; import React from "react"; import { FollowButton } from "./FollowButton"; +import { router } from "expo-router"; type Follower = { + id: string; name: string; username: string; avatar: string; @@ -13,12 +15,12 @@ type FollowerItemProps = { }; const FollowerItem = ({ follower }: FollowerItemProps) => { + console.log(follower.id); return ( - + router.push(`/friend/${follower.id}`)}> {follower.username} diff --git a/frontend/components/review/ReviewPreview.tsx b/frontend/components/review/ReviewPreview.tsx index 2ed188aa..ae1883af 100644 --- a/frontend/components/review/ReviewPreview.tsx +++ b/frontend/components/review/ReviewPreview.tsx @@ -6,16 +6,18 @@ import { Colors } from "@/constants/Colors"; import Entypo from "@expo/vector-icons/build/Entypo"; import { router } from "expo-router"; import { ReviewComponentStarIcon } from "../icons/Icons"; +import type { User } from '@/context/user-context'; -type Props = { +type ReviewProps = { plateName: string; restaurantName: string; tags: string[]; rating: number; content: string; + user: User; }; -const ReviewPreview = ({ plateName, restaurantName, tags, rating, content }: Props) => { +const ReviewPreview = ({ plateName, restaurantName, tags, rating, content, user }: ReviewProps) => { return ( } - icon={"https://avatars.githubusercontent.com/u/66958528?v=4"} - onPress={() => router.push(`/friend`)} + // default profile picture + icon={user?.profile_picture || "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png"} + onPress={() => router.push(`/friend/${user?.id || "67e300bc43b432515e2dd8ba"}`)} /> diff --git a/frontend/context/user-context.tsx b/frontend/context/user-context.tsx index 519e4bab..4a66f511 100644 --- a/frontend/context/user-context.tsx +++ b/frontend/context/user-context.tsx @@ -12,7 +12,7 @@ export interface User { username: string; followersCount: number; followingCount: number; - // preferences: string[]; + preferences: string[]; } interface UserContextType { diff --git a/frontend/types/review.ts b/frontend/types/review.ts new file mode 100644 index 00000000..42ed4c0d --- /dev/null +++ b/frontend/types/review.ts @@ -0,0 +1,14 @@ +export type Review = { + id: string; + plateName: string; + restaurantName: string; + tags: string[]; + rating: { + overall: number, + portion: number, + return: boolean, + taste: number, + value: number, + }, + content: string; +}; \ No newline at end of file From d545e601f413cf0522c39111cb71503f109251ae Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Sun, 6 Apr 2025 22:42:55 -0400 Subject: [PATCH 19/26] cleaned up main --- .../handlers/users/user_connections.go | 1 - backend/xutils/xutils.go | 1 - frontend/app/(profile)/[id].tsx | 73 +++--------------- frontend/app/(tabs)/profile/settings.tsx | 2 +- frontend/app/RestaurantView.tsx | 2 +- frontend/app/friend/[userId].tsx | 75 +++---------------- frontend/components/review/ReviewPreview.tsx | 12 ++- frontend/components/ui/StarReview.tsx | 2 +- 8 files changed, 30 insertions(+), 138 deletions(-) diff --git a/backend/internal/handlers/users/user_connections.go b/backend/internal/handlers/users/user_connections.go index b82b6e55..3c0f7013 100644 --- a/backend/internal/handlers/users/user_connections.go +++ b/backend/internal/handlers/users/user_connections.go @@ -8,7 +8,6 @@ import ( "github.com/gofiber/fiber/v2" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/bson/primitive" ) type Handler struct { diff --git a/backend/xutils/xutils.go b/backend/xutils/xutils.go index 41837d93..1056cb33 100644 --- a/backend/xutils/xutils.go +++ b/backend/xutils/xutils.go @@ -2,7 +2,6 @@ package xutils import "crypto/rand" import "go.mongodb.org/mongo-driver/bson/primitive" -import "go.mongodb.org/mongo-driver/bson/primitive" func GenerateOTP(length int) (string, error) { diff --git a/frontend/app/(profile)/[id].tsx b/frontend/app/(profile)/[id].tsx index 5eccbafc..43b786fe 100644 --- a/frontend/app/(profile)/[id].tsx +++ b/frontend/app/(profile)/[id].tsx @@ -8,42 +8,13 @@ import { Ionicons } from "@expo/vector-icons"; import ProfileAvatar from "@/components/profile/ProfileAvatar"; import ProfileIdentity from "@/components/profile/ProfileIdentity"; import ProfileMetrics from "@/components/profile/ProfileMetrics"; -<<<<<<<< HEAD:frontend/app/(profile)/[id].tsx -import { EditProfileButton } from "@/components/profile/EditProfileButton"; -import { router, useLocalSearchParams, useNavigation } from "expo-router"; -import EditProfileSheet from "@/components/profile/EditProfileSheet"; -import ReviewPreview from "@/components/review/ReviewPreview"; -import { SearchBoxFilter } from "@/components/SearchBoxFilter"; - -const { width } = Dimensions.get("window"); - -const user = { - username: "lrollo02", - name: "Danny Rollo", - reviews: 12, - friends: 19, - averageRating: 4.2, - profilePicture: "https://randomuser.me/api/portraits/men/44.jpg", -}; - -const ProfileScreen = () => { - const { id } = useLocalSearchParams<{ id: string }>(); - - const navigation = useNavigation(); - - useEffect(() => { - navigation.setOptions({ headerShown: false }); - }, [navigation]); - - const [searchText, setSearchText] = React.useState(""); -======== import ReviewPreview from "@/components/review/ReviewPreview"; import { SearchBoxFilter } from "@/components/SearchBoxFilter"; import EditFriendSheet from "@/components/profile/followers/FriendProfileOptions"; import { FollowButton } from "@/components/profile/followers/FollowButton"; import { useLocalSearchParams } from 'expo-router'; import type { User } from '@/context/user-context'; -import type { Review } from '@/types/review'; +import type { TReview } from '@/types/review'; const { width } = Dimensions.get("window"); @@ -64,7 +35,7 @@ const ProfileScreen = () => { followingCount: 0, preferences: [] }); //initialziing the user to an empty user - const [userReviews, setUserReviews] = useState([]); + const [userReviews, setUserReviews] = useState([]); const [isLoading, setLoading] = useState(true); useEffect(() => { @@ -120,7 +91,6 @@ const ProfileScreen = () => { // // ); // } ->>>>>>>> featurethon-user-profile:frontend/app/friend/[userId].tsx return ( @@ -131,24 +101,7 @@ const ProfileScreen = () => { start={{ x: 0, y: 0 }} end={{ x: 0, y: 1 }} /> -<<<<<<<< HEAD:frontend/app/(profile)/[id].tsx - - - - - - - - {user.name.split(" ")[0]}'s Food Journal -======== { console.log("Button Pressed!"); }}> @@ -164,35 +117,29 @@ const ProfileScreen = () => { {user.name}'s Food Journal ->>>>>>>> featurethon-user-profile:frontend/app/friend/[userId].tsx {/* Made a search box with a filter/sort component as its own component */} >>>>>>> featurethon-user-profile:frontend/app/friend/[userId].tsx recent={true} onSubmit={() => console.log("submit")} value={searchText} onChangeText={(text) => setSearchText(text)} /> -<<<<<<<< HEAD:frontend/app/(profile)/[id].tsx -======== {userReviews.map((review) => ( + authorName={user.name} + authorUsername={user.username} + authorAvatar={user.profile_picture || "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png"} + authorId={user.id}> ))} ->>>>>>>> featurethon-user-profile:frontend/app/friend/[userId].tsx diff --git a/frontend/app/(tabs)/profile/settings.tsx b/frontend/app/(tabs)/profile/settings.tsx index 2569d0e6..da621451 100644 --- a/frontend/app/(tabs)/profile/settings.tsx +++ b/frontend/app/(tabs)/profile/settings.tsx @@ -2,7 +2,7 @@ import { ScrollView, View, StyleSheet, TextInput, Text } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useRouter } from "expo-router"; import ToggleSetting from "@/components/profile/settings/ToggleSetting"; import SettingsSection from "@/components/profile/settings/SettingsSection"; diff --git a/frontend/app/RestaurantView.tsx b/frontend/app/RestaurantView.tsx index 4cdb2276..67d8fd74 100644 --- a/frontend/app/RestaurantView.tsx +++ b/frontend/app/RestaurantView.tsx @@ -110,7 +110,7 @@ export default function RestaurantView() { setActiveTab={setActiveTab} /> - router.push("/(review)/827b36v4b234")}> + router.push(`/(review)/${"827b36v4b234"}` as any)}> { - const { id } = useLocalSearchParams<{ id: string }>(); - - const navigation = useNavigation(); - - useEffect(() => { - navigation.setOptions({ headerShown: false }); - }, [navigation]); - - const [searchText, setSearchText] = React.useState(""); -======== import ReviewPreview from "@/components/review/ReviewPreview"; import { SearchBoxFilter } from "@/components/SearchBoxFilter"; import EditFriendSheet from "@/components/profile/followers/FriendProfileOptions"; import { FollowButton } from "@/components/profile/followers/FollowButton"; import { useLocalSearchParams } from 'expo-router'; import type { User } from '@/context/user-context'; -import type { Review } from '@/types/review'; +import type { TReview } from '@/types/review'; const { width } = Dimensions.get("window"); @@ -64,7 +35,7 @@ const ProfileScreen = () => { followingCount: 0, preferences: [] }); //initialziing the user to an empty user - const [userReviews, setUserReviews] = useState([]); + const [userReviews, setUserReviews] = useState([]); const [isLoading, setLoading] = useState(true); useEffect(() => { @@ -120,7 +91,6 @@ const ProfileScreen = () => { // // ); // } ->>>>>>>> featurethon-user-profile:frontend/app/friend/[userId].tsx return ( @@ -131,24 +101,7 @@ const ProfileScreen = () => { start={{ x: 0, y: 0 }} end={{ x: 0, y: 1 }} /> -<<<<<<<< HEAD:frontend/app/(profile)/[id].tsx - - - - - - - - {user.name.split(" ")[0]}'s Food Journal -======== { console.log("Button Pressed!"); }}> @@ -164,35 +117,31 @@ const ProfileScreen = () => { {user.name}'s Food Journal ->>>>>>>> featurethon-user-profile:frontend/app/friend/[userId].tsx {/* Made a search box with a filter/sort component as its own component */} >>>>>>> featurethon-user-profile:frontend/app/friend/[userId].tsx recent={true} onSubmit={() => console.log("submit")} value={searchText} onChangeText={(text) => setSearchText(text)} /> -<<<<<<<< HEAD:frontend/app/(profile)/[id].tsx -======== {userReviews.map((review) => ( + authorName={user.name} + authorUsername={user.username} + authorAvatar={user.profile_picture || "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png"} + authorId={user.id} + > ))} ->>>>>>>> featurethon-user-profile:frontend/app/friend/[userId].tsx diff --git a/frontend/components/review/ReviewPreview.tsx b/frontend/components/review/ReviewPreview.tsx index 19830e5c..b302b256 100644 --- a/frontend/components/review/ReviewPreview.tsx +++ b/frontend/components/review/ReviewPreview.tsx @@ -6,7 +6,6 @@ import { Colors } from "@/constants/Colors"; import Entypo from "@expo/vector-icons/build/Entypo"; import { router, useNavigation } from "expo-router"; import { ReviewComponentStarIcon } from "../icons/Icons"; -import type { User } from '@/context/user-context'; type ReviewProps = { plateName: string; @@ -14,14 +13,13 @@ type ReviewProps = { tags: string[]; rating: number; content: string; - user: User; authorName: string; authorUsername: string; authorAvatar: string; authorId: string; }; -const ReviewPreview = ({ plateName, restaurantName, tags, rating, content, authorName, authorUsername, authorAvatar, authorId, user }: ReviewProps) => { +const ReviewPreview = ({ plateName, restaurantName, tags, rating, content, authorName, authorUsername, authorAvatar, authorId }: ReviewProps) => { return ( } // default profile picture - icon={user?.profile_picture || "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png"} - onPress={() => router.push(`/friend/${user?.id || "67e300bc43b432515e2dd8ba"}`)} + icon={authorAvatar} + onPress={() => router.push(`/friend/${authorId}`)} /> diff --git a/frontend/components/ui/StarReview.tsx b/frontend/components/ui/StarReview.tsx index 00bda794..3f6789b6 100644 --- a/frontend/components/ui/StarReview.tsx +++ b/frontend/components/ui/StarReview.tsx @@ -79,7 +79,7 @@ export function InteractiveStars({ {Array.from({ length: maxStars }).map((_, i) => { const isFilled = i < rating; - const StarIcon = isFilled ? ShadedStar : UnshadedStar; + const StarShaded = isFilled ? ShadedStar : UnshadedStar; return ( onChange(i + 1)}> Date: Sun, 6 Apr 2025 22:48:49 -0400 Subject: [PATCH 20/26] linted frontend code --- backend/xutils/xutils.go | 6 +- frontend/app/(profile)/[id].tsx | 115 +++++++++-------- frontend/app/(tabs)/profile/profile.tsx | 38 +++--- frontend/app/friend/[userId].tsx | 118 +++++++++--------- .../profile/followers/FollowerItem.tsx | 5 +- frontend/components/review/ReviewPreview.tsx | 16 ++- 6 files changed, 160 insertions(+), 138 deletions(-) diff --git a/backend/xutils/xutils.go b/backend/xutils/xutils.go index 1056cb33..cebf64d0 100644 --- a/backend/xutils/xutils.go +++ b/backend/xutils/xutils.go @@ -1,8 +1,10 @@ package xutils -import "crypto/rand" -import "go.mongodb.org/mongo-driver/bson/primitive" +import ( + "crypto/rand" + "go.mongodb.org/mongo-driver/bson/primitive" +) func GenerateOTP(length int) (string, error) { diff --git a/frontend/app/(profile)/[id].tsx b/frontend/app/(profile)/[id].tsx index 43b786fe..a02d092e 100644 --- a/frontend/app/(profile)/[id].tsx +++ b/frontend/app/(profile)/[id].tsx @@ -12,19 +12,19 @@ import ReviewPreview from "@/components/review/ReviewPreview"; import { SearchBoxFilter } from "@/components/SearchBoxFilter"; import EditFriendSheet from "@/components/profile/followers/FriendProfileOptions"; import { FollowButton } from "@/components/profile/followers/FollowButton"; -import { useLocalSearchParams } from 'expo-router'; -import type { User } from '@/context/user-context'; -import type { TReview } from '@/types/review'; +import { useLocalSearchParams } from "expo-router"; +import type { User } from "@/context/user-context"; +import type { TReview } from "@/types/review"; const { width } = Dimensions.get("window"); const ProfileScreen = () => { console.log("hi"); - const {userId} = useLocalSearchParams(); + const { userId } = useLocalSearchParams(); console.log(userId); const [searchText, setSearchText] = useState(""); const editFriend = useRef<{ open: () => void; close: () => void }>(null); - + const [user, setUser] = useState({ id: "", email: "", @@ -33,46 +33,46 @@ const ProfileScreen = () => { username: "", followersCount: 0, followingCount: 0, - preferences: [] - }); //initialziing the user to an empty user + preferences: [], + }); //initialziing the user to an empty user const [userReviews, setUserReviews] = useState([]); const [isLoading, setLoading] = useState(true); - + useEffect(() => { - const fetchUserAndReviews = async () => { - if (!userId) return ; - - setLoading(true); - try { - const userRes = await fetch( - `https://externally-exotic-orca.ngrok-free.app/api/v1/user/${userId}`); - const userData = await userRes.json(); - console.log(userData); - const newUser: User = { - id: userData.id, - email: userData.email, - name: userData.name, - profile_picture: userData.profile_picture, - username: userData.username, - followersCount: userData.followersCount, - followingCount: userData.followingCount, - preferences: userData.preferences, - }; - setUser(newUser); + const fetchUserAndReviews = async () => { + if (!userId) return; + + setLoading(true); + try { + const userRes = await fetch(`https://externally-exotic-orca.ngrok-free.app/api/v1/user/${userId}`); + const userData = await userRes.json(); + console.log(userData); + const newUser: User = { + id: userData.id, + email: userData.email, + name: userData.name, + profile_picture: userData.profile_picture, + username: userData.username, + followersCount: userData.followersCount, + followingCount: userData.followingCount, + preferences: userData.preferences, + }; + setUser(newUser); + + const reviewsRes = await fetch( + `https://externally-exotic-orca.ngrok-free.app/api/v1/review/user/${userId}`, + ); + const reviewData = await reviewsRes.json(); + console.log(reviewData); + setUserReviews(reviewData); + } catch (err) { + console.error("Failed to fetch user by ID", err); + } finally { + setLoading(false); + } + }; - const reviewsRes = await fetch( - `https://externally-exotic-orca.ngrok-free.app/api/v1/review/user/${userId}`); - const reviewData = await reviewsRes.json(); - console.log(reviewData); - setUserReviews(reviewData); - } catch (err) { - console.error("Failed to fetch user by ID", err); - } finally { - setLoading(false); - } - }; - - fetchUserAndReviews(); + fetchUserAndReviews(); }, [userId]); if (isLoading) { @@ -109,7 +109,12 @@ const ProfileScreen = () => { {/* inserted a default profile picture because profile_picture is string | undefined */} - + @@ -120,7 +125,7 @@ const ProfileScreen = () => { {/* Made a search box with a filter/sort component as its own component */} console.log("submit")} value={searchText} @@ -128,17 +133,19 @@ const ProfileScreen = () => { /> {userReviews.map((review) => ( - + key={review._id} + plateName={review.menuItem} + restaurantName={review.restaurantId} + tags={[]} + rating={review.rating.overall} + content={review.content} + authorName={user.name} + authorUsername={user.username} + authorAvatar={ + user.profile_picture || + "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png" + } + authorId={user.id}> ))} diff --git a/frontend/app/(tabs)/profile/profile.tsx b/frontend/app/(tabs)/profile/profile.tsx index 67890c75..e71935c0 100644 --- a/frontend/app/(tabs)/profile/profile.tsx +++ b/frontend/app/(tabs)/profile/profile.tsx @@ -13,7 +13,7 @@ import { router } from "expo-router"; import EditProfileSheet from "@/components/profile/EditProfileSheet"; import ReviewPreview from "@/components/review/ReviewPreview"; import { SearchBoxFilter } from "@/components/SearchBoxFilter"; -import type { Review } from '@/types/review'; +import type { Review } from "@/types/review"; const { width } = Dimensions.get("window"); @@ -30,17 +30,18 @@ const ProfileScreen = () => { fetchUserProfile().then(() => {}); } const fetchReviews = async () => { - if (!user?.id) return ; - - try { - const reviewsRes = await fetch( - `https://externally-exotic-orca.ngrok-free.app/api/v1/review/user/${user.id}`); - const reviewData = await reviewsRes.json(); - console.log(reviewData); - setUserReviews(reviewData); - } catch (err) { - console.error("Failed to fetch user by ID", err); - } + if (!user?.id) return; + + try { + const reviewsRes = await fetch( + `https://externally-exotic-orca.ngrok-free.app/api/v1/review/user/${user.id}`, + ); + const reviewData = await reviewsRes.json(); + console.log(reviewData); + setUserReviews(reviewData); + } catch (err) { + console.error("Failed to fetch user by ID", err); + } }; fetchReviews(); }, [user, isLoading]); @@ -100,13 +101,12 @@ const ProfileScreen = () => { /> {userReviews.map((review) => ( - + plateName={review.plateName} + restaurantName={review.restaurantName} + tags={review.tags || []} + rating={review.rating.overall} + content={review.content} + user={user}> ))} diff --git a/frontend/app/friend/[userId].tsx b/frontend/app/friend/[userId].tsx index d57d46ed..2654307a 100644 --- a/frontend/app/friend/[userId].tsx +++ b/frontend/app/friend/[userId].tsx @@ -12,19 +12,19 @@ import ReviewPreview from "@/components/review/ReviewPreview"; import { SearchBoxFilter } from "@/components/SearchBoxFilter"; import EditFriendSheet from "@/components/profile/followers/FriendProfileOptions"; import { FollowButton } from "@/components/profile/followers/FollowButton"; -import { useLocalSearchParams } from 'expo-router'; -import type { User } from '@/context/user-context'; -import type { TReview } from '@/types/review'; +import { useLocalSearchParams } from "expo-router"; +import type { User } from "@/context/user-context"; +import type { TReview } from "@/types/review"; const { width } = Dimensions.get("window"); const ProfileScreen = () => { console.log("hi"); - const {userId} = useLocalSearchParams(); + const { userId } = useLocalSearchParams(); console.log(userId); const [searchText, setSearchText] = useState(""); const editFriend = useRef<{ open: () => void; close: () => void }>(null); - + const [user, setUser] = useState({ id: "", email: "", @@ -33,46 +33,46 @@ const ProfileScreen = () => { username: "", followersCount: 0, followingCount: 0, - preferences: [] - }); //initialziing the user to an empty user + preferences: [], + }); //initialziing the user to an empty user const [userReviews, setUserReviews] = useState([]); const [isLoading, setLoading] = useState(true); - + useEffect(() => { - const fetchUserAndReviews = async () => { - if (!userId) return ; - - setLoading(true); - try { - const userRes = await fetch( - `https://externally-exotic-orca.ngrok-free.app/api/v1/user/${userId}`); - const userData = await userRes.json(); - console.log(userData); - const newUser: User = { - id: userData.id, - email: userData.email, - name: userData.name, - profile_picture: userData.profile_picture, - username: userData.username, - followersCount: userData.followersCount, - followingCount: userData.followingCount, - preferences: userData.preferences, - }; - setUser(newUser); + const fetchUserAndReviews = async () => { + if (!userId) return; + + setLoading(true); + try { + const userRes = await fetch(`https://externally-exotic-orca.ngrok-free.app/api/v1/user/${userId}`); + const userData = await userRes.json(); + console.log(userData); + const newUser: User = { + id: userData.id, + email: userData.email, + name: userData.name, + profile_picture: userData.profile_picture, + username: userData.username, + followersCount: userData.followersCount, + followingCount: userData.followingCount, + preferences: userData.preferences, + }; + setUser(newUser); + + const reviewsRes = await fetch( + `https://externally-exotic-orca.ngrok-free.app/api/v1/review/user/${userId}`, + ); + const reviewData = await reviewsRes.json(); + console.log(reviewData); + setUserReviews(reviewData); + } catch (err) { + console.error("Failed to fetch user by ID", err); + } finally { + setLoading(false); + } + }; - const reviewsRes = await fetch( - `https://externally-exotic-orca.ngrok-free.app/api/v1/review/user/${userId}`); - const reviewData = await reviewsRes.json(); - console.log(reviewData); - setUserReviews(reviewData); - } catch (err) { - console.error("Failed to fetch user by ID", err); - } finally { - setLoading(false); - } - }; - - fetchUserAndReviews(); + fetchUserAndReviews(); }, [userId]); if (isLoading) { @@ -109,7 +109,12 @@ const ProfileScreen = () => { {/* inserted a default profile picture because profile_picture is string | undefined */} - + @@ -120,7 +125,7 @@ const ProfileScreen = () => { {/* Made a search box with a filter/sort component as its own component */} console.log("submit")} value={searchText} @@ -128,19 +133,20 @@ const ProfileScreen = () => { /> {userReviews.map((review) => ( - + key={review._id} + plateName={review.menuItem} + // we dont have a restaurant name or tags in the defined review type right now + restaurantName={review.restaurantId} + tags={[]} + rating={review.rating.overall} + content={review.content} + authorName={user.name} + authorUsername={user.username} + authorAvatar={ + user.profile_picture || + "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png" + } + authorId={user.id}> ))} diff --git a/frontend/components/profile/followers/FollowerItem.tsx b/frontend/components/profile/followers/FollowerItem.tsx index f8764be1..ce4bdb8a 100644 --- a/frontend/components/profile/followers/FollowerItem.tsx +++ b/frontend/components/profile/followers/FollowerItem.tsx @@ -18,10 +18,7 @@ const FollowerItem = ({ follower }: FollowerItemProps) => { console.log(follower.id); return ( router.push(`/friend/${follower.id}`)}> - + {follower.username} {follower.name} diff --git a/frontend/components/review/ReviewPreview.tsx b/frontend/components/review/ReviewPreview.tsx index b302b256..38cdda64 100644 --- a/frontend/components/review/ReviewPreview.tsx +++ b/frontend/components/review/ReviewPreview.tsx @@ -19,7 +19,17 @@ type ReviewProps = { authorId: string; }; -const ReviewPreview = ({ plateName, restaurantName, tags, rating, content, authorName, authorUsername, authorAvatar, authorId }: ReviewProps) => { +const ReviewPreview = ({ + plateName, + restaurantName, + tags, + rating, + content, + authorName, + authorUsername, + authorAvatar, + authorId, +}: ReviewProps) => { return ( } - // default profile picture - icon={authorAvatar} + // default profile picture + icon={authorAvatar} onPress={() => router.push(`/friend/${authorId}`)} /> From b73716c6bdf05dd249f10e76695058ed2c87af5e Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Tue, 8 Apr 2025 01:46:33 -0400 Subject: [PATCH 21/26] fixed changes --- frontend/api/reviews.tsx | 5 ++ frontend/app/(profile)/[id].tsx | 5 +- frontend/app/(tabs)/index/index.tsx | 33 ++++++------ frontend/app/(tabs)/profile/friends.tsx | 28 +++-------- frontend/app/(tabs)/profile/profile.tsx | 35 +++++++------ frontend/app/(tabs)/profile/settings.tsx | 53 +++++++------------- frontend/app/friend/[userId].tsx | 29 ++++++----- frontend/components/AuthInitializer.tsx | 2 +- frontend/components/SearchBoxFilter.tsx | 2 +- frontend/components/review/ReviewPreview.tsx | 2 +- frontend/context/user-context.tsx | 1 + 11 files changed, 93 insertions(+), 102 deletions(-) diff --git a/frontend/api/reviews.tsx b/frontend/api/reviews.tsx index 97fb42cb..6b1eda2d 100644 --- a/frontend/api/reviews.tsx +++ b/frontend/api/reviews.tsx @@ -8,3 +8,8 @@ export const getReviews = async (page: number = 1, limit: number = 20): Promise< export const getReviewById = async (id: string): Promise => { return await makeRequest(`/api/v1/review/${id}`, "GET"); }; + +export const getFriendsReviews = async (id: string): Promise => { + return await makeRequest(`/api/v1/item/${id}/followReviews`, "GET"); +}; + diff --git a/frontend/app/(profile)/[id].tsx b/frontend/app/(profile)/[id].tsx index a02d092e..3f81cf6a 100644 --- a/frontend/app/(profile)/[id].tsx +++ b/frontend/app/(profile)/[id].tsx @@ -14,6 +14,7 @@ import EditFriendSheet from "@/components/profile/followers/FriendProfileOptions import { FollowButton } from "@/components/profile/followers/FollowButton"; import { useLocalSearchParams } from "expo-router"; import type { User } from "@/context/user-context"; +import { DEFAULT_PROFILE_PIC } from "@/context/user-context"; import type { TReview } from "@/types/review"; const { width } = Dimensions.get("window"); @@ -112,7 +113,7 @@ const ProfileScreen = () => { @@ -143,7 +144,7 @@ const ProfileScreen = () => { authorUsername={user.username} authorAvatar={ user.profile_picture || - "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png" + DEFAULT_PROFILE_PIC } authorId={user.id}> ))} diff --git a/frontend/app/(tabs)/index/index.tsx b/frontend/app/(tabs)/index/index.tsx index c46c1fd6..cd93b023 100644 --- a/frontend/app/(tabs)/index/index.tsx +++ b/frontend/app/(tabs)/index/index.tsx @@ -77,21 +77,24 @@ export default function Feed() { {reviews.length > 0 ? ( - {reviews.map((item: TReview, index: number) => ( - router.push(`/(review)/${item._id}`)}> - - - ))} + {reviews.map((item: TReview, index: number) => { + console.log(item); + return ( + router.push(`/(review)/${item._id}`)}> + + + ); + })} ) : ( diff --git a/frontend/app/(tabs)/profile/friends.tsx b/frontend/app/(tabs)/profile/friends.tsx index 8550f831..89ab6ed5 100644 --- a/frontend/app/(tabs)/profile/friends.tsx +++ b/frontend/app/(tabs)/profile/friends.tsx @@ -6,6 +6,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import FollowerItem from "@/components/profile/followers/FollowerItem"; import { TFriend } from "@/types/follower"; import useAuthStore from "@/auth/store"; +import { makeRequest } from "@/api/base"; + export default function FriendsScreen() { const insets = useSafeAreaInsets(); @@ -28,10 +30,12 @@ export default function FriendsScreen() { } try { - const response = await fetch( - `https://externally-exotic-orca.ngrok-free.app/api/v1/user/${userId}/following`, - ); - const data = await response.json(); + const data = await makeRequest( + `/api/v1/user/${userId}/following`, + "GET"); + if (!data) { + throw new Error(data.message || "an unknown error occurred"); + } if (data && data.length > 0) { const formattedUsers = data.map((user) => ({ @@ -42,22 +46,6 @@ export default function FriendsScreen() { })); setFriends(formattedUsers); - - // dont think this stuff is needed anymore but left it in case - // if (isRefresh) { - // setFollowers(formattedUsers); - // setPage(2); - // } else if (pageNum === 1) { - // setFollowers(formattedUsers); - // setPage(2); - // } else { - // setFollowers((prevFollowers) => [...prevFollowers, ...formattedUsers]); - // setPage(pageNum + 1); - // } - - // setHasMoreData(pageNum < 15); - } else { - // setHasMoreData(false); } } catch (error) { console.error("Error fetching users:", error); diff --git a/frontend/app/(tabs)/profile/profile.tsx b/frontend/app/(tabs)/profile/profile.tsx index e71935c0..e66aa3ef 100644 --- a/frontend/app/(tabs)/profile/profile.tsx +++ b/frontend/app/(tabs)/profile/profile.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; -import { useUser } from "@/context/user-context"; +import { DEFAULT_PROFILE_PIC, useUser } from "@/context/user-context"; import { ThemedView } from "@/components/themed/ThemedView"; import { ActivityIndicator, Dimensions, ScrollView, StatusBar, StyleSheet, TouchableOpacity } from "react-native"; import { ThemedText } from "@/components/themed/ThemedText"; @@ -13,14 +13,15 @@ import { router } from "expo-router"; import EditProfileSheet from "@/components/profile/EditProfileSheet"; import ReviewPreview from "@/components/review/ReviewPreview"; import { SearchBoxFilter } from "@/components/SearchBoxFilter"; -import type { Review } from "@/types/review"; +import type { TReview } from "@/types/review"; +import { makeRequest } from "@/api/base"; const { width } = Dimensions.get("window"); const ProfileScreen = () => { const { user, isLoading, error, fetchUserProfile } = useUser(); const [searchText, setSearchText] = React.useState(""); - const [userReviews, setUserReviews] = useState([]); + const [userReviews, setUserReviews] = useState([]); const editProfileRef = useRef<{ open: () => void; close: () => void }>(null); @@ -33,11 +34,12 @@ const ProfileScreen = () => { if (!user?.id) return; try { - const reviewsRes = await fetch( - `https://externally-exotic-orca.ngrok-free.app/api/v1/review/user/${user.id}`, - ); - const reviewData = await reviewsRes.json(); - console.log(reviewData); + const reviewData = await makeRequest( + `/api/v1/review/user/${user.id}`, + "GET"); + if (!reviewData) { + throw new Error(reviewData.message || "an unknown error occurred"); + } setUserReviews(reviewData); } catch (err) { console.error("Failed to fetch user by ID", err); @@ -76,9 +78,9 @@ const ProfileScreen = () => { - + - + router.navigate("/profile/settings")} /> { /> {userReviews.map((review) => ( + authorId={user.id} + authorName={user.name} + authorUsername={user.username} + authorAvatar={user.profile_picture || DEFAULT_PROFILE_PIC}> + ))} diff --git a/frontend/app/(tabs)/profile/settings.tsx b/frontend/app/(tabs)/profile/settings.tsx index da621451..17cf33b3 100644 --- a/frontend/app/(tabs)/profile/settings.tsx +++ b/frontend/app/(tabs)/profile/settings.tsx @@ -11,6 +11,7 @@ import { TSettingsData } from "@/types/settingsData"; import useAuthStore from "@/auth/store"; import { Button } from "@/components/Button"; import axios from "axios"; +import { makeRequest } from "@/api/base"; export default function SettingsScreen() { const insets = useSafeAreaInsets(); @@ -18,7 +19,7 @@ export default function SettingsScreen() { const { email, userId } = useAuthStore(); // const userIdStr = JSON.stringify(userId); - console.log(userId); + console.log(userId, email); const { logout } = useAuthStore(); @@ -46,20 +47,18 @@ export default function SettingsScreen() { ); useEffect(() => { - console.log("fetched restrictions!"); - const fetchDietaryRestrictions = async () => { - try { - const response = await axios.get( - `https://externally-exotic-orca.ngrok-free.app/api/v1/settings/${userId}/dietaryPreferences`, - ); - setDietaryPreferences(response.data); - } catch (err) { - console.log("Failed to fetch dietary restrictions"); - console.error(err); + console.log("fetched preferences!"); + const fetchDietaryPreferences = async () => { + const preferencesData = await makeRequest( + `/api/v1/settings/${userId}/dietaryPreferences`, + "GET"); + if (!preferencesData) { + throw new Error(preferencesData.message || "failed to fetch dietary preferences"); } - }; + setDietaryPreferences(preferencesData); + } - fetchDietaryRestrictions(); + fetchDietaryPreferences(); console.log(dietaryPreferences); }, [userId]); @@ -89,31 +88,15 @@ export default function SettingsScreen() { }; const handleAddDietaryPreference = async (preference: string) => { - try { - const response = await axios.post( - `https://externally-exotic-orca.ngrok-free.app/api/v1/settings/${userId}/dietaryPreferences?preference=${dietaryOptions[preference]}`, - ); - if (response.status === 201) { - console.log("Preference added successfully:", preference); - } - } catch (err) { - console.log("Failed to add dietary preference"); - console.error(err); - } + await makeRequest( + `/api/v1/settings/${userId}/dietaryPreferences?preference=${dietaryOptions[preference]}`, + "POST", null, "Failed to add dietary preference"); }; const handleRemoveDietaryPreference = async (preference: string) => { - try { - const response = await axios.delete( - `https://externally-exotic-orca.ngrok-free.app/api/v1/settings/${userId}/dietaryPreferences?preference=${dietaryOptions[preference]}`, - ); - if (response.status === 201) { - console.log("Preference deleted successfully:", preference); - } - } catch (err) { - console.log("Failed to delete dietary preference"); - console.error(err); - } + await makeRequest( + `/api/v1/settings/${userId}/dietaryPreferences?preference=${dietaryOptions[preference]}`, + "DELETE", null, "Failed to remove dietary preference"); }; const settingsData: TSettingsData = { diff --git a/frontend/app/friend/[userId].tsx b/frontend/app/friend/[userId].tsx index 2654307a..a4a343dc 100644 --- a/frontend/app/friend/[userId].tsx +++ b/frontend/app/friend/[userId].tsx @@ -1,5 +1,4 @@ import React, { useEffect, useRef, useState } from "react"; -import { useUser } from "@/context/user-context"; import { ThemedView } from "@/components/themed/ThemedView"; import { ActivityIndicator, Dimensions, ScrollView, StatusBar, StyleSheet, TouchableOpacity } from "react-native"; import { ThemedText } from "@/components/themed/ThemedText"; @@ -10,11 +9,12 @@ import ProfileIdentity from "@/components/profile/ProfileIdentity"; import ProfileMetrics from "@/components/profile/ProfileMetrics"; import ReviewPreview from "@/components/review/ReviewPreview"; import { SearchBoxFilter } from "@/components/SearchBoxFilter"; -import EditFriendSheet from "@/components/profile/followers/FriendProfileOptions"; import { FollowButton } from "@/components/profile/followers/FollowButton"; import { useLocalSearchParams } from "expo-router"; import type { User } from "@/context/user-context"; +import { DEFAULT_PROFILE_PIC } from "@/context/user-context"; import type { TReview } from "@/types/review"; +import { makeRequest } from "@/api/base"; const { width } = Dimensions.get("window"); @@ -44,9 +44,12 @@ const ProfileScreen = () => { setLoading(true); try { - const userRes = await fetch(`https://externally-exotic-orca.ngrok-free.app/api/v1/user/${userId}`); - const userData = await userRes.json(); - console.log(userData); + const userData = await makeRequest( + `/api/v1/user/${userId}`, + "GET"); + if (!userData) { + throw new Error(userData.message || "failed to retrieve ther user"); + } const newUser: User = { id: userData.id, email: userData.email, @@ -59,10 +62,12 @@ const ProfileScreen = () => { }; setUser(newUser); - const reviewsRes = await fetch( - `https://externally-exotic-orca.ngrok-free.app/api/v1/review/user/${userId}`, - ); - const reviewData = await reviewsRes.json(); + const reviewData = await makeRequest( + `/api/v1/review/user/${userId}`, + "GET"); + if (!reviewData) { + throw new Error(reviewData.message || "failed to retrieve user reviews"); + } console.log(reviewData); setUserReviews(reviewData); } catch (err) { @@ -111,8 +116,7 @@ const ProfileScreen = () => { {/* inserted a default profile picture because profile_picture is string | undefined */} @@ -143,8 +147,7 @@ const ProfileScreen = () => { authorName={user.name} authorUsername={user.username} authorAvatar={ - user.profile_picture || - "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png" + user.profile_picture || DEFAULT_PROFILE_PIC } authorId={user.id}> ))} diff --git a/frontend/components/AuthInitializer.tsx b/frontend/components/AuthInitializer.tsx index 63fd4285..8f62d856 100644 --- a/frontend/components/AuthInitializer.tsx +++ b/frontend/components/AuthInitializer.tsx @@ -12,7 +12,7 @@ export const AuthInitializer = ({ children }: { children: React.ReactNode }) => // Initialize auth when the app starts console.log("Initializing authentication..."); initializeAuth(); - }); // Empty dependency array ensures this runs only once + }, []); // Empty dependency array ensures this runs only once return <>{children}; }; diff --git a/frontend/components/SearchBoxFilter.tsx b/frontend/components/SearchBoxFilter.tsx index 350ef6c2..46673f6a 100644 --- a/frontend/components/SearchBoxFilter.tsx +++ b/frontend/components/SearchBoxFilter.tsx @@ -15,7 +15,7 @@ export function SearchBoxFilter({ value, onChangeText, onSubmit, icon, recent, n const [recentItems, setRecentItems] = useState([]); const fetchRecents = useCallback(async () => { - const recents = getRecents(); + const recents = await getRecents(); setRecentItems(recents); }, [getRecents]); diff --git a/frontend/components/review/ReviewPreview.tsx b/frontend/components/review/ReviewPreview.tsx index 38cdda64..f392bd5f 100644 --- a/frontend/components/review/ReviewPreview.tsx +++ b/frontend/components/review/ReviewPreview.tsx @@ -44,7 +44,7 @@ const ReviewPreview = ({ height: Dimensions.get("window").height * 0.36, }}> } // default profile picture diff --git a/frontend/context/user-context.tsx b/frontend/context/user-context.tsx index 4a66f511..6a09011a 100644 --- a/frontend/context/user-context.tsx +++ b/frontend/context/user-context.tsx @@ -3,6 +3,7 @@ import axios from "axios"; import useAuthStore from "@/auth/store"; const API_BASE_URL = process.env.EXPO_PUBLIC_BASE_URL; +export const DEFAULT_PROFILE_PIC = "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png"; export interface User { id: string; From 3394d92f65e2ac137c7fb86b053c73446d988630 Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Tue, 8 Apr 2025 01:47:45 -0400 Subject: [PATCH 22/26] fixed error in allReviews --- frontend/components/review/AllReviews.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/components/review/AllReviews.tsx b/frontend/components/review/AllReviews.tsx index e5d1c477..3843e17b 100644 --- a/frontend/components/review/AllReviews.tsx +++ b/frontend/components/review/AllReviews.tsx @@ -67,13 +67,13 @@ export default function AllReviews() { {/* Sample Review Preview */} - + /> */} console.log("Open review flow")} /> From 8c60e5a2ec8bc80f68ac13d17df1ca6a3e202969 Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Tue, 8 Apr 2025 01:49:39 -0400 Subject: [PATCH 23/26] linted the frontend --- frontend/api/reviews.tsx | 1 - frontend/app/(profile)/[id].tsx | 12 ++---------- frontend/app/(tabs)/index/index.tsx | 2 +- frontend/app/(tabs)/profile/friends.tsx | 11 ++++------- frontend/app/(tabs)/profile/profile.tsx | 13 +++++-------- frontend/app/(tabs)/profile/settings.tsx | 18 +++++++++++------- frontend/app/friend/[userId].tsx | 18 ++++-------------- frontend/context/user-context.tsx | 3 ++- 8 files changed, 29 insertions(+), 49 deletions(-) diff --git a/frontend/api/reviews.tsx b/frontend/api/reviews.tsx index 6b1eda2d..6c9d4fc4 100644 --- a/frontend/api/reviews.tsx +++ b/frontend/api/reviews.tsx @@ -12,4 +12,3 @@ export const getReviewById = async (id: string): Promise => { export const getFriendsReviews = async (id: string): Promise => { return await makeRequest(`/api/v1/item/${id}/followReviews`, "GET"); }; - diff --git a/frontend/app/(profile)/[id].tsx b/frontend/app/(profile)/[id].tsx index 3f81cf6a..4b41f404 100644 --- a/frontend/app/(profile)/[id].tsx +++ b/frontend/app/(profile)/[id].tsx @@ -110,12 +110,7 @@ const ProfileScreen = () => { {/* inserted a default profile picture because profile_picture is string | undefined */} - + @@ -142,10 +137,7 @@ const ProfileScreen = () => { content={review.content} authorName={user.name} authorUsername={user.username} - authorAvatar={ - user.profile_picture || - DEFAULT_PROFILE_PIC - } + authorAvatar={user.profile_picture || DEFAULT_PROFILE_PIC} authorId={user.id}> ))} diff --git a/frontend/app/(tabs)/index/index.tsx b/frontend/app/(tabs)/index/index.tsx index cd93b023..8ae08b76 100644 --- a/frontend/app/(tabs)/index/index.tsx +++ b/frontend/app/(tabs)/index/index.tsx @@ -78,7 +78,7 @@ export default function Feed() { {reviews.length > 0 ? ( {reviews.map((item: TReview, index: number) => { - console.log(item); + console.log(item); return ( router.push(`/(review)/${item._id}`)}> 0) { const formattedUsers = data.map((user) => ({ diff --git a/frontend/app/(tabs)/profile/profile.tsx b/frontend/app/(tabs)/profile/profile.tsx index e66aa3ef..4a3c3421 100644 --- a/frontend/app/(tabs)/profile/profile.tsx +++ b/frontend/app/(tabs)/profile/profile.tsx @@ -34,12 +34,10 @@ const ProfileScreen = () => { if (!user?.id) return; try { - const reviewData = await makeRequest( - `/api/v1/review/user/${user.id}`, - "GET"); - if (!reviewData) { - throw new Error(reviewData.message || "an unknown error occurred"); - } + const reviewData = await makeRequest(`/api/v1/review/user/${user.id}`, "GET"); + if (!reviewData) { + throw new Error(reviewData.message || "Failed to retrieve user reviews"); + } setUserReviews(reviewData); } catch (err) { console.error("Failed to fetch user by ID", err); @@ -112,8 +110,7 @@ const ProfileScreen = () => { authorId={user.id} authorName={user.name} authorUsername={user.username} - authorAvatar={user.profile_picture || DEFAULT_PROFILE_PIC}> - + authorAvatar={user.profile_picture || DEFAULT_PROFILE_PIC}> ))} diff --git a/frontend/app/(tabs)/profile/settings.tsx b/frontend/app/(tabs)/profile/settings.tsx index 17cf33b3..6dc756f6 100644 --- a/frontend/app/(tabs)/profile/settings.tsx +++ b/frontend/app/(tabs)/profile/settings.tsx @@ -49,14 +49,12 @@ export default function SettingsScreen() { useEffect(() => { console.log("fetched preferences!"); const fetchDietaryPreferences = async () => { - const preferencesData = await makeRequest( - `/api/v1/settings/${userId}/dietaryPreferences`, - "GET"); + const preferencesData = await makeRequest(`/api/v1/settings/${userId}/dietaryPreferences`, "GET"); if (!preferencesData) { throw new Error(preferencesData.message || "failed to fetch dietary preferences"); } setDietaryPreferences(preferencesData); - } + }; fetchDietaryPreferences(); console.log(dietaryPreferences); @@ -89,14 +87,20 @@ export default function SettingsScreen() { const handleAddDietaryPreference = async (preference: string) => { await makeRequest( - `/api/v1/settings/${userId}/dietaryPreferences?preference=${dietaryOptions[preference]}`, - "POST", null, "Failed to add dietary preference"); + `/api/v1/settings/${userId}/dietaryPreferences?preference=${dietaryOptions[preference]}`, + "POST", + null, + "Failed to add dietary preference", + ); }; const handleRemoveDietaryPreference = async (preference: string) => { await makeRequest( `/api/v1/settings/${userId}/dietaryPreferences?preference=${dietaryOptions[preference]}`, - "DELETE", null, "Failed to remove dietary preference"); + "DELETE", + null, + "Failed to remove dietary preference", + ); }; const settingsData: TSettingsData = { diff --git a/frontend/app/friend/[userId].tsx b/frontend/app/friend/[userId].tsx index a4a343dc..bc4ebfe5 100644 --- a/frontend/app/friend/[userId].tsx +++ b/frontend/app/friend/[userId].tsx @@ -44,9 +44,7 @@ const ProfileScreen = () => { setLoading(true); try { - const userData = await makeRequest( - `/api/v1/user/${userId}`, - "GET"); + const userData = await makeRequest(`/api/v1/user/${userId}`, "GET"); if (!userData) { throw new Error(userData.message || "failed to retrieve ther user"); } @@ -62,9 +60,7 @@ const ProfileScreen = () => { }; setUser(newUser); - const reviewData = await makeRequest( - `/api/v1/review/user/${userId}`, - "GET"); + const reviewData = await makeRequest(`/api/v1/review/user/${userId}`, "GET"); if (!reviewData) { throw new Error(reviewData.message || "failed to retrieve user reviews"); } @@ -114,11 +110,7 @@ const ProfileScreen = () => { {/* inserted a default profile picture because profile_picture is string | undefined */} - + @@ -146,9 +138,7 @@ const ProfileScreen = () => { content={review.content} authorName={user.name} authorUsername={user.username} - authorAvatar={ - user.profile_picture || DEFAULT_PROFILE_PIC - } + authorAvatar={user.profile_picture || DEFAULT_PROFILE_PIC} authorId={user.id}> ))} diff --git a/frontend/context/user-context.tsx b/frontend/context/user-context.tsx index 6a09011a..74bcfb2b 100644 --- a/frontend/context/user-context.tsx +++ b/frontend/context/user-context.tsx @@ -3,7 +3,8 @@ import axios from "axios"; import useAuthStore from "@/auth/store"; const API_BASE_URL = process.env.EXPO_PUBLIC_BASE_URL; -export const DEFAULT_PROFILE_PIC = "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png"; +export const DEFAULT_PROFILE_PIC = + "https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png"; export interface User { id: string; From 7aa75fc99e164079a2b610fa5ebdc69ef1f315ff Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Wed, 9 Apr 2025 20:42:11 -0400 Subject: [PATCH 24/26] menu items displayed on the feed are from the backend --- frontend/api/menu-items.ts | 4 + frontend/app/(tabs)/index/index.tsx | 14 +- frontend/bun.lockb | Bin 549803 -> 549835 bytes frontend/components/SearchBoxFilter.tsx | 2 +- frontend/components/review/ReviewPreview.tsx | 169 +++++++++---------- frontend/package.json | 1 + 6 files changed, 96 insertions(+), 94 deletions(-) diff --git a/frontend/api/menu-items.ts b/frontend/api/menu-items.ts index a59f2820..a05f4542 100644 --- a/frontend/api/menu-items.ts +++ b/frontend/api/menu-items.ts @@ -41,3 +41,7 @@ export const getRestaurantMenuItemsMetrics = async (restaurantId: string): Promi export const getRandomMenuItems = async (limit: number): Promise => { return await makeRequest(`/api/v1/menu-items/random?limit=${limit}`, "GET"); }; + +export const getFriendMenuItems = async (id: string, limit: number): Promise => { + return await makeRequest(`/api/v1/menu-items/popular-with-friends?userId=${id}&limit=${limit}`, "GET"); +} \ No newline at end of file diff --git a/frontend/app/(tabs)/index/index.tsx b/frontend/app/(tabs)/index/index.tsx index 27b7ecee..fa573af5 100644 --- a/frontend/app/(tabs)/index/index.tsx +++ b/frontend/app/(tabs)/index/index.tsx @@ -5,7 +5,7 @@ import { ThemedView } from "@/components/themed/ThemedView"; import FeedTabs from "@/components/Feed/FeedTabs"; import ReviewPreview from "@/components/review/ReviewPreview"; import MenuItemPreview from "@/components/Cards/MenuItemPreview"; -import { getMenuItems, getRandomMenuItems } from "@/api/menu-items"; +import { getMenuItems, getFriendMenuItems } from "@/api/menu-items"; import { TMenuItem } from "@/types/menu-item"; import { TReview } from "@/types/review"; import { getReviews } from "@/api/reviews"; @@ -15,6 +15,7 @@ import { SearchIcon } from "@/components/icons/Icons"; import { FilterContext } from "@/context/filter-context"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ThemedText } from "@/components/themed/ThemedText"; +import { useUser } from "@/context/user-context"; // Define a type for our feed items type FeedItem = { @@ -30,6 +31,7 @@ export default function Feed() { const [feedItems, setFeedItems] = useState([]); const [reviews, setReviews] = useState([]); const [menuItems, setMenuItems] = useState([]); + const { user } = useUser(); const insets = useSafeAreaInsets(); const router = useRouter(); @@ -47,11 +49,14 @@ export default function Feed() { const fetchData = useCallback(async () => { try { - const [reviewsData, menuItemsData] = await Promise.all([getReviews(2, 20), getRandomMenuItems(20)]); + if (!user) { + throw new Error("User is null. Cannot fetch friend menu items."); + } + + const [reviewsData, menuItemsData] = await Promise.all([getReviews(2, 20), getFriendMenuItems(user.id, 20)]); const fetchedReviews = reviewsData.data as TReview[]; const fetchedMenuItems = menuItemsData as TMenuItem[]; - setReviews(fetchedReviews); setMenuItems(fetchedMenuItems); @@ -74,7 +79,7 @@ export default function Feed() { setLoading(false); setRefreshing(false); } - }, []); + }, [user]); useEffect(() => { setLoading(true); @@ -186,7 +191,6 @@ export default function Feed() { filter={true} /> - {reviews.length > 0 ? ( diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 893452406fdd7400d9f400051f622a4101116d92..6f08c464cf49ff878606c6c62ff8731e1be60288 100755 GIT binary patch delta 87245 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 WFT9$3<4jZUWO9Fe$cf};?EeAx(z8ea delta 86384 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 - } - // default profile picture - icon={authorAvatar} - onPress={() => router.push(`/friend/${authorId}`)} - /> - - - - router.push(`/(profile)/${authorId}`)} - right={null} - /> - - + - + router.push(`/(profile)/${authorId}`)} + right={null} + /> + + + - {rating} - - + + {rating} + + + - - - - - - - {plateName} - - - {restaurantName} - + + + + + + {plateName} + + + {restaurantName} + + + + {tags.map((tag, index) => ( + + ))} + + + {content} + - - {tags.map((tag, index) => ( - - ))} - - - {content} - - - - - - - - 0 - - - - 0 - - - - - + + + + + + 0 + + + + 0 + + + + + + diff --git a/frontend/package.json b/frontend/package.json index 44af6cf5..5dc15b87 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -54,6 +54,7 @@ "react-native-svg-transformer": "^1.5.0", "react-native-web": "~0.19.13", "react-native-webview": "13.12.5", + "ws": "8", "zustand": "^5.0.3" }, "devDependencies": { From fa59ae96e897da2d29948bb7afdc1bfe774422d0 Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Thu, 10 Apr 2025 02:56:21 -0400 Subject: [PATCH 25/26] connected the menu item reviews to the backend --- .../internal/handlers/menu_items/service.go | 2 +- backend/internal/handlers/menu_items/types.go | 11 +- frontend/api/menu-items.ts | 5 + frontend/api/restaurant.ts | 17 +- frontend/api/{reviews.tsx => reviews.ts} | 2 +- frontend/app/(menuItem)/[id].tsx | 86 ++++++--- frontend/app/(profile)/[id].tsx | 4 +- frontend/app/(tabs)/index/index.tsx | 12 +- frontend/app/(tabs)/profile/profile.tsx | 4 +- frontend/app/MenuItemView.tsx | 4 +- frontend/app/friend/[userId].tsx | 4 +- frontend/components/SearchBoxFilter.tsx | 174 +++++++++--------- .../components/restaurant/HighlightCard.tsx | 15 +- frontend/components/ui/StarReview.tsx | 5 + frontend/types/restaurant.ts | 5 + 15 files changed, 214 insertions(+), 136 deletions(-) rename frontend/api/{reviews.tsx => reviews.ts} (87%) diff --git a/backend/internal/handlers/menu_items/service.go b/backend/internal/handlers/menu_items/service.go index 557d91ff..26a596c7 100644 --- a/backend/internal/handlers/menu_items/service.go +++ b/backend/internal/handlers/menu_items/service.go @@ -540,7 +540,7 @@ func (s *Service) GetPopularWithFriends(userID primitive.ObjectID, limit int) ([ "$match": bson.M{ "$expr": bson.M{ "$in": bson.A{ - bson.M{"$toObjectId": "$reviewer.id"}, + bson.M{"$toObjectId": "$reviewer._id"}, "$$friendIDs", }, }, diff --git a/backend/internal/handlers/menu_items/types.go b/backend/internal/handlers/menu_items/types.go index f1e017f3..4215f71e 100644 --- a/backend/internal/handlers/menu_items/types.go +++ b/backend/internal/handlers/menu_items/types.go @@ -17,6 +17,7 @@ type MenuItemRequest struct { Name string `json:"name"` Picture string `json:"picture"` Reviews []string `json:"reviews"` + AvgRating AvgRatingDocument `json:"avgRating"` Description string `json:"description"` Location []float64 `json:"location"` Tags []string `json:"tags"` @@ -86,11 +87,11 @@ type MenuItemDocument struct { } type AvgRatingDocument struct { - Portion float64 `bson:"portion"` - Taste float64 `bson:"taste"` - Value float64 `bson:"value"` - Overall float64 `bson:"overall"` - Return float64 `bson:"return"` // @TODO: figure out if boolean or number + Portion float64 `bson:"portion" json:"portion"` + Taste float64 `bson:"taste" json:"taste"` + Value float64 `bson:"value" json:"value"` + Overall float64 `bson:"overall" json:"overall"` + Return float64 `bson:"return" json:"return"` // @TODO: figure out if boolean or number } // MenuItemMetrics represents analytics data for a single menu item diff --git a/frontend/api/menu-items.ts b/frontend/api/menu-items.ts index a05f4542..a201372b 100644 --- a/frontend/api/menu-items.ts +++ b/frontend/api/menu-items.ts @@ -1,4 +1,5 @@ import { TMenuItem } from "@/types/menu-item"; +import { TReview } from "@/types/review"; import { makeRequest } from "@/api/base"; import { TRestaurantMenuItemsMetrics } from "@/types/restaurant"; @@ -44,4 +45,8 @@ export const getRandomMenuItems = async (limit: number): Promise => export const getFriendMenuItems = async (id: string, limit: number): Promise => { return await makeRequest(`/api/v1/menu-items/popular-with-friends?userId=${id}&limit=${limit}`, "GET"); +} + +export const getMenuItemReviews = async (menuItemId: string): Promise => { + return await makeRequest(`/api/v1/menu-items/${menuItemId}/reviews`, "GET"); } \ No newline at end of file diff --git a/frontend/api/restaurant.ts b/frontend/api/restaurant.ts index 4321fdcb..11af58d2 100644 --- a/frontend/api/restaurant.ts +++ b/frontend/api/restaurant.ts @@ -1,7 +1,20 @@ import { TRestaurant } from "@/types/restaurant"; +import { FriendsFavInfo } from "@/types/restaurant"; import { makeRequest } from "@/api/base"; export const getRestaurant = async (id: string): Promise => { - const res = makeRequest("/api/v1/restaurant/" + id, "GET"); - return res; + return await makeRequest(`/api/v1/restaurant/${id}`, "GET"); }; + +export const getRestaurantFriendsFav = async (userId: string, restaurantId: string): Promise => { + const data = await makeRequest(`/api/v1/restaurant/${userId}/${restaurantId}`, "GET"); + const formattedFriendsFav: FriendsFavInfo = { + isFriendsFav: data.friends_fav, + numFriends: data.friends_reviewed + }; + return formattedFriendsFav; +} + +export const getRestaurantSuperStars = async (restaurantId: string): Promise => { + return await makeRequest(`/api/v1/restaurant/${restaurantId}/super-stars`, "GET"); +} \ No newline at end of file diff --git a/frontend/api/reviews.tsx b/frontend/api/reviews.ts similarity index 87% rename from frontend/api/reviews.tsx rename to frontend/api/reviews.ts index d3d2a3a0..34948006 100644 --- a/frontend/api/reviews.tsx +++ b/frontend/api/reviews.ts @@ -10,5 +10,5 @@ export const getReviewById = async (id: string, userId: string): Promise => { - return await makeRequest(`/api/v1/item/${id}/followReviews`, "GET"); + return await makeRequest(`/api/v1/item/${id}/followingReviews`, "GET"); }; diff --git a/frontend/app/(menuItem)/[id].tsx b/frontend/app/(menuItem)/[id].tsx index ae76c1ed..91133d5c 100644 --- a/frontend/app/(menuItem)/[id].tsx +++ b/frontend/app/(menuItem)/[id].tsx @@ -2,24 +2,35 @@ import { ThemedView } from "@/components/themed/ThemedView"; import { ScrollView, StyleSheet, View, Image, Pressable, TouchableOpacity } from "react-native"; import { ThemedText } from "@/components/themed/ThemedText"; import { StarRating } from "@/components/ui/StarReview"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useCallback } from "react"; import { Ionicons } from "@expo/vector-icons"; import ReviewPreview from "@/components/review/ReviewPreview"; import { ThemedTag } from "@/components/themed/ThemedTag"; import { ReviewButton } from "@/components/review/ReviewButton"; import HighlightCard from "@/components/restaurant/HighlightCard"; -import { PersonWavingIcon, RestaurantIcon, ThumbsUpIcon } from "@/components/icons/Icons"; +import { PersonWavingIcon, RestaurantIcon, SmileyIcon, ThumbsUpIcon } from "@/components/icons/Icons"; import { useLocalSearchParams, useNavigation, useRouter } from "expo-router"; -import { getMenuItemById } from "@/api/menu-items"; +import { getMenuItemById, getMenuItemReviews } from "@/api/menu-items"; import { TMenuItem } from "@/types/menu-item"; import ReviewFlow from "@/components/review/ReviewFlow"; import AddReviewButton from "@/components/AddReviewButton"; import { Skeleton } from "moti/skeleton"; +import { useUser } from "@/context/user-context"; +import { getRestaurantFriendsFav, getRestaurantSuperStars } from "@/api/restaurant"; +import { FriendsFavInfo } from "@/types/restaurant"; +import { TReview } from "@/types/review"; export default function Route() { const [selectedFilter, setSelectedFilter] = React.useState("My Reviews"); + const [friendsFav, setFriendsFav] = useState({ + isFriendsFav: false, + numFriends: 0, + }); + const [superStars, setSuperStars] = useState(0); + const [menuItemReviews, setMenuItemReviews] = useState([]) const { id } = useLocalSearchParams<{ id: string }>(); + const { user } = useUser(); const navigation = useNavigation(); const [menuItem, setMenuItem] = useState(null); @@ -29,13 +40,42 @@ export default function Route() { const router = useRouter(); + const fetchData = useCallback(async () => { + try { + if (!user || !menuItem) { + console.log("USER", user); + console.log("MENUITEM", menuItem); + throw new Error("User and/or menuItem are null. Cannot fetch associated menu item data."); + } + const [friendsFavData, superStarsData, menuItemReviewData] = await Promise.all([getRestaurantFriendsFav(user.id, menuItem.restaurantID), getRestaurantSuperStars(menuItem.restaurantID), getMenuItemReviews(menuItem.id)]); + console.log("MENUITEMID", menuItem); + console.log("MENUREVIEWDATA", menuItemReviewData); + console.log("FRIENDSFAVDATA", friendsFavData); + setFriendsFav(friendsFavData); + setSuperStars(superStarsData); + setMenuItemReviews(menuItemReviewData); + + } catch (error) { + console.error("Error fetching data:", error); + } finally { + setLoading(false); + } + }, [user, menuItem]); + useEffect(() => { navigation.setOptions({ headerShown: false }); getMenuItemById(id).then((data) => { setMenuItem(data); setLoading(false); }); - }, [navigation]); + }, [navigation, id]); + + useEffect(() => { + // Only call fetchData when menuItem is initialized + if (menuItem) { + fetchData(); + } + }, [menuItem, user, fetchData]); return ( <> @@ -119,15 +159,15 @@ export default function Route() { } /> } /> - + } /> @@ -138,8 +178,8 @@ export default function Route() { - 4/5 - + {menuItem?.avgRating.overall} + @@ -162,18 +202,22 @@ export default function Route() { ))} - - + {menuItemReviews.map((item: TReview, index: number) => ( + router.push(`/(review)/${item._id}`)}> + + + ))} diff --git a/frontend/app/(profile)/[id].tsx b/frontend/app/(profile)/[id].tsx index 9b5c9d38..95c6bb4f 100644 --- a/frontend/app/(profile)/[id].tsx +++ b/frontend/app/(profile)/[id].tsx @@ -9,7 +9,7 @@ import ProfileAvatar from "@/components/profile/ProfileAvatar"; import ProfileIdentity from "@/components/profile/ProfileIdentity"; import ProfileMetrics from "@/components/profile/ProfileMetrics"; import ReviewPreview from "@/components/review/ReviewPreview"; -import { SearchBoxFilter } from "@/components/SearchBoxFilter"; +import { SearchBox } from "@/components/SearchBox"; import EditFriendSheet from "@/components/profile/followers/FriendProfileOptions"; import { FollowButton } from "@/components/profile/followers/FollowButton"; import { useLocalSearchParams } from "expo-router"; @@ -120,7 +120,7 @@ const ProfileScreen = () => { {user.name}'s Food Journal {/* Made a search box with a filter/sort component as its own component */} - console.log("submit")} diff --git a/frontend/app/(tabs)/index/index.tsx b/frontend/app/(tabs)/index/index.tsx index fa573af5..ac5c46ff 100644 --- a/frontend/app/(tabs)/index/index.tsx +++ b/frontend/app/(tabs)/index/index.tsx @@ -50,9 +50,9 @@ export default function Feed() { const fetchData = useCallback(async () => { try { if (!user) { - throw new Error("User is null. Cannot fetch friend menu items."); + throw new Error("User is null. Cannot fetch friend menu items and reviews."); } - + console.log("user id:", user.id); const [reviewsData, menuItemsData] = await Promise.all([getReviews(2, 20), getFriendMenuItems(user.id, 20)]); const fetchedReviews = reviewsData.data as TReview[]; @@ -84,6 +84,7 @@ export default function Feed() { useEffect(() => { setLoading(true); fetchData(); + console.log("FMI", menuItems); }, [fetchData]); const onRefresh = useCallback(() => { @@ -221,18 +222,19 @@ export default function Feed() { {menuItems.length > 0 && ( - {menuItems.map((item: TMenuItem, index: number) => ( + {/* {menuItems.map((item: TMenuItem, index: number) => ( router.push(`/(menuItem)/${item.id}`)}> - ))} + ))} */} )} diff --git a/frontend/app/(tabs)/profile/profile.tsx b/frontend/app/(tabs)/profile/profile.tsx index 8a5c5e96..ca6e5867 100644 --- a/frontend/app/(tabs)/profile/profile.tsx +++ b/frontend/app/(tabs)/profile/profile.tsx @@ -12,7 +12,7 @@ import { EditProfileButton } from "@/components/profile/EditProfileButton"; import { router } from "expo-router"; import EditProfileSheet from "@/components/profile/EditProfileSheet"; import ReviewPreview from "@/components/review/ReviewPreview"; -import { SearchBoxFilter } from "@/components/SearchBoxFilter"; +import { SearchBox } from "@/components/SearchBox"; import type { TReview } from "@/types/review"; import { makeRequest } from "@/api/base"; @@ -92,7 +92,7 @@ const ProfileScreen = () => { {user.name.split(" ")[0]}'s Food Journal - } /> } /> - + } /> {/* Reviews Section */} diff --git a/frontend/app/friend/[userId].tsx b/frontend/app/friend/[userId].tsx index 31097428..30952132 100644 --- a/frontend/app/friend/[userId].tsx +++ b/frontend/app/friend/[userId].tsx @@ -8,7 +8,7 @@ import ProfileAvatar from "@/components/profile/ProfileAvatar"; import ProfileIdentity from "@/components/profile/ProfileIdentity"; import ProfileMetrics from "@/components/profile/ProfileMetrics"; import ReviewPreview from "@/components/review/ReviewPreview"; -import { SearchBoxFilter } from "@/components/SearchBoxFilter"; +import { SearchBox } from "@/components/SearchBox"; import { FollowButton } from "@/components/profile/followers/FollowButton"; import { useLocalSearchParams } from "expo-router"; import type { User } from "@/context/user-context"; @@ -120,7 +120,7 @@ const ProfileScreen = () => { {user.name}'s Food Journal {/* Made a search box with a filter/sort component as its own component */} - console.log("submit")} diff --git a/frontend/components/SearchBoxFilter.tsx b/frontend/components/SearchBoxFilter.tsx index a4874b3b..e0a0187e 100644 --- a/frontend/components/SearchBoxFilter.tsx +++ b/frontend/components/SearchBoxFilter.tsx @@ -1,95 +1,95 @@ -import React, { useRef} from "react"; -import { TextInput, StyleSheet, View, TouchableOpacity } from "react-native"; -import { useThemeColor } from "@/hooks/useThemeColor"; -import { SearchBoxProps } from "./SearchBox"; -import { SortIcon } from "./icons/Icons"; +// import React, { useRef, useState, useCallback} from "react"; +// import { TextInput, StyleSheet, View, TouchableOpacity } from "react-native"; +// import { useThemeColor } from "@/hooks/useThemeColor"; +// import { SearchBoxProps } from "./SearchBox"; +// import { SortIcon } from "./icons/Icons"; -export function SearchBoxFilter({ value, onChangeText, onSubmit, icon, recent, name, ...rest }: SearchBoxProps) { - const textColor = useThemeColor({ light: "#000", dark: "#fff" }, "text"); - const inputRef = useRef(null); - const [recentItems, setRecentItems] = useState([]); +// export function SearchBoxFilter({ value, onChangeText, onSubmit, icon, recent, name, ...rest }: SearchBoxProps) { +// const textColor = useThemeColor({ light: "#000", dark: "#fff" }, "text"); +// const inputRef = useRef(null); +// const [recentItems, setRecentItems] = useState([]); - const fetchRecents = useCallback(async () => { - const recents = await getRecents(); - setRecentItems(recents); - }, [getRecents]); +// const fetchRecents = useCallback(async () => { +// const recents = await getRecents(); +// setRecentItems(recents); +// }, [getRecents]); - async function clearRecents() { - setRecentItems([]); - } +// async function clearRecents() { +// setRecentItems([]); +// } - useEffect(() => { - if (inputRef.current) { - inputRef.current?.measureInWindow((height) => { - setInputHeight(height + Dimensions.get("window").height * 0.01); - }); - } - }, [inputRef]); +// useEffect(() => { +// if (inputRef.current) { +// inputRef.current?.measureInWindow((height) => { +// setInputHeight(height + Dimensions.get("window").height * 0.01); +// }); +// } +// }, [inputRef]); - useEffect(() => { - fetchRecents(); - }, [recent, fetchRecents]); +// useEffect(() => { +// fetchRecents(); +// }, [recent, fetchRecents]); - const onSubmitEditing = () => { - if (recent) - appendSearch(value).then(() => { - fetchRecents(); - }); - onSubmit(); - }; +// const onSubmitEditing = () => { +// if (recent) +// appendSearch(value).then(() => { +// fetchRecents(); +// }); +// onSubmit(); +// }; - return ( - - - - - - {icon && icon} - - - - {}}> - - - - ); -} +// return ( +// +// +// +// +// +// {icon && icon} +// +// +// +// {}}> +// +// +// +// ); +// } -const styles = StyleSheet.create({ - container: { - flexDirection: "row", - alignItems: "center", - borderWidth: 1, - borderColor: "#DDD", - borderRadius: 12, - paddingHorizontal: 12, - paddingVertical: 8, - fontFamily: "Source Sans 3", - }, - input: { - flex: 1, - fontFamily: "Source Sans 3", - }, - icon: { - marginLeft: 8, - resizeMode: "contain", - }, - searchContainer: { - flexDirection: "row", - alignItems: "center", - width: "100%", - justifyContent: "space-between", - }, - searchBoxContainer: { - flex: 1, - marginRight: 10, - }, -}); +// const styles = StyleSheet.create({ +// container: { +// flexDirection: "row", +// alignItems: "center", +// borderWidth: 1, +// borderColor: "#DDD", +// borderRadius: 12, +// paddingHorizontal: 12, +// paddingVertical: 8, +// fontFamily: "Source Sans 3", +// }, +// input: { +// flex: 1, +// fontFamily: "Source Sans 3", +// }, +// icon: { +// marginLeft: 8, +// resizeMode: "contain", +// }, +// searchContainer: { +// flexDirection: "row", +// alignItems: "center", +// width: "100%", +// justifyContent: "space-between", +// }, +// searchBoxContainer: { +// flex: 1, +// marginRight: 10, +// }, +// }); diff --git a/frontend/components/restaurant/HighlightCard.tsx b/frontend/components/restaurant/HighlightCard.tsx index ab3777f6..92b6d81a 100644 --- a/frontend/components/restaurant/HighlightCard.tsx +++ b/frontend/components/restaurant/HighlightCard.tsx @@ -1,12 +1,15 @@ import { SmileyIcon } from "@/components/icons/Icons"; import { View, Text, StyleSheet } from "react-native"; -const HighlightCard = ({ - icon = , - title = "Super Stars", - subtitle = "200+ Five Stars", - backgroundColor = "#F7F9FC", -}) => { +interface HighlightCardProps { + title: string; + subtitle: string; + icon: React.JSX.Element; +} + +const backgroundColor = "#F7F9FC" + +const HighlightCard = ({ title, subtitle, icon }: HighlightCardProps) => { return ( {icon} diff --git a/frontend/components/ui/StarReview.tsx b/frontend/components/ui/StarReview.tsx index 0d24659a..49512b11 100644 --- a/frontend/components/ui/StarReview.tsx +++ b/frontend/components/ui/StarReview.tsx @@ -48,6 +48,11 @@ export function StarRating({ {showNumRatingsText ? " reviews" : ""}) )} + {numRatings == 0 && showNumRatings && ( + + ({"There are no reviews to be displayed"}) + + )} ); } diff --git a/frontend/types/restaurant.ts b/frontend/types/restaurant.ts index 811a5ad5..1dc26a4f 100644 --- a/frontend/types/restaurant.ts +++ b/frontend/types/restaurant.ts @@ -29,3 +29,8 @@ export type TRestaurantMenuItemsMetrics = { total_reviews: number; menu_item_metrics: TMenuItemMetrics[]; }; + +export type FriendsFavInfo = { + isFriendsFav: boolean; + numFriends: number; +} From fe323aabf0f109c6af79dbc8ebd107c456a171a1 Mon Sep 17 00:00:00 2001 From: Sierra Welsch Date: Fri, 11 Apr 2025 00:47:11 -0400 Subject: [PATCH 26/26] completed # 3 --- frontend/app/(tabs)/index/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/(tabs)/index/index.tsx b/frontend/app/(tabs)/index/index.tsx index ac5c46ff..38dc3692 100644 --- a/frontend/app/(tabs)/index/index.tsx +++ b/frontend/app/(tabs)/index/index.tsx @@ -85,7 +85,7 @@ export default function Feed() { setLoading(true); fetchData(); console.log("FMI", menuItems); - }, [fetchData]); + }, [fetchData, user]); const onRefresh = useCallback(() => { setRefreshing(true);