diff --git a/backend/src/main/java/com/rimatch/rimatchbackend/controller/MatchController.java b/backend/src/main/java/com/rimatch/rimatchbackend/controller/MatchController.java index 8dc7b13..8625691 100644 --- a/backend/src/main/java/com/rimatch/rimatchbackend/controller/MatchController.java +++ b/backend/src/main/java/com/rimatch/rimatchbackend/controller/MatchController.java @@ -36,10 +36,11 @@ public class MatchController { private InfobipClient infobipClient; @GetMapping("/potential") - public List getPotentinalMatch(HttpServletRequest request){ + public List getPotentialMatch(HttpServletRequest request, @RequestParam(value = "skip", required = false) Integer skip) { String authToken = request.getHeader("Authorization"); User user = userService.getUserByToken(authToken); - List list = matchService.findPotentialMatches(user); + int skipValue = skip != null ? skip : 0; + List list = matchService.findPotentialMatches(user, skipValue); return list; } diff --git a/backend/src/main/java/com/rimatch/rimatchbackend/service/MatchService.java b/backend/src/main/java/com/rimatch/rimatchbackend/service/MatchService.java index 01fb929..fe543ff 100644 --- a/backend/src/main/java/com/rimatch/rimatchbackend/service/MatchService.java +++ b/backend/src/main/java/com/rimatch/rimatchbackend/service/MatchService.java @@ -66,7 +66,7 @@ public Match finishMatch(Match match, boolean status) { return matchRepository.save(match); } - public List findPotentialMatches(User user) { + public List findPotentialMatches(User user, int skip) { return DisplayUserConverter.convertToDtoList(mongoTemplate.aggregate( Aggregation.newAggregation( @@ -81,7 +81,8 @@ public List findPotentialMatches(User user) { // Second age parametar needs to defined separatenly because of limitations of // the org.bson.Document Aggregation.match(Criteria.where("age").gte(user.getPreferences().getAgeGroupMin())), - Aggregation.limit(10)), + Aggregation.skip(skip), + Aggregation.limit(5)), "users", User.class).getMappedResults()); } diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index f061a47..bc19260 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -1,3 +1,4 @@ +import useAuth from "@/hooks/useAuth"; import useAxiosPrivate from "@/hooks/useAxiosPrivate"; import type { Match, MatchData } from "@/types/Match"; import type { @@ -15,9 +16,10 @@ import { export const UsersService = { useGetCurrentUser() { + const { auth } = useAuth(); const axios = useAxiosPrivate(); return useQuery({ - queryKey: ["UsersService.getCurrentUser"], + queryKey: ["UsersService.getCurrentUser", auth?.accessToken], queryFn: () => axios.get("/users/me").then((response) => response.data), staleTime: Infinity, }); @@ -41,15 +43,31 @@ export const UsersService = { }); }, - useGetPotentailUsers: () => { + useGetPotentailUsers: (page: number) => { const axios = useAxiosPrivate(); return useQuery({ - queryKey: ["UsersService.getPotentialUsers"], + queryKey: ["UsersService.getPotentialUsers", page], queryFn: () => axios.get("/match/potential").then((res) => res.data), - staleTime: Infinity, + staleTime: 60e3, }); }, + usePrefetchPotentialUsers: (page: number) => { + const axios = useAxiosPrivate(); + const queryClient = useQueryClient(); + + const prefetch = () => { + queryClient.prefetchQuery({ + queryKey: ["UsersService.getPotentialUsers", page + 1], + queryFn: () => + axios.get(`/match/potential?skip=3`).then((res) => res.data), + staleTime: 60e3, + }); + }; + + return prefetch; + }, + useAcceptMatch: ( mutationOptions?: Omit< UseMutationOptions, @@ -57,17 +75,11 @@ export const UsersService = { > ) => { const axios = useAxiosPrivate(); - const queryClient = useQueryClient(); return useMutation({ mutationFn: async (data) => { const response = await axios.post("/match/accept", data); return response.data; }, - onSuccess: () => { - return queryClient.invalidateQueries({ - queryKey: ["UsersService.getPotentialUsers"], - }); - }, ...mutationOptions, }); }, @@ -79,17 +91,11 @@ export const UsersService = { > ) => { const axios = useAxiosPrivate(); - const queryClient = useQueryClient(); return useMutation({ mutationFn: async (data) => { const response = await axios.post("/match/reject", data); return response.data; }, - onSuccess: () => { - return queryClient.invalidateQueries({ - queryKey: ["UsersService.getCurrentUser"], - }); - }, ...mutationOptions, }); }, diff --git a/frontend/src/components/MatchCard.tsx b/frontend/src/components/MatchCard.tsx index 9024009..1e01117 100644 --- a/frontend/src/components/MatchCard.tsx +++ b/frontend/src/components/MatchCard.tsx @@ -2,31 +2,87 @@ import ClearIcon from "@mui/icons-material/Clear"; import CheckIcon from "@mui/icons-material/Check"; import { UsersService } from "@/api/users"; import cx from "classnames"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ProfileCard } from "./ProfileCard"; +import { useQueryClient } from "@tanstack/react-query"; +import { CircularProgress } from "@mui/material"; + +const PAGE_SIZE = 5; + const MatchCard = () => { - const result = UsersService.useGetPotentailUsers(); + const [page, setPage] = useState(0); + const [currentUserIndex, setCurrentUserIndex] = useState(0); + const result = UsersService.useGetPotentailUsers(page); const acceptMatch = UsersService.useAcceptMatch(); const rejectMatch = UsersService.useRejectMatch(); const [isProfileOpen, setIsProfileOpen] = useState(false); + const prefetchPotential = UsersService.usePrefetchPotentialUsers(page); + const queryClient = useQueryClient(); + + const handleNextUser = (matchAccept: boolean, userId: string) => { + const match = matchAccept ? acceptMatch.mutate : rejectMatch.mutate; + if (currentUserIndex === PAGE_SIZE - 3) { + prefetchPotential(); + } + match({ userId }); + if (currentUserIndex === PAGE_SIZE - 1) { + setPage((prev) => prev + 1); + setCurrentUserIndex(0); + } else { + setCurrentUserIndex((prev) => prev + 1); + } + }; + + // When the component unmounts it is necessary to invalidate the query + // because the query is cached and it will return the same data + // but the new component will start from index 0 of the array + useEffect(() => { + return () => { + queryClient.invalidateQueries({ + queryKey: ["UsersService.getPotentialUsers"], + }); + }; + }, []); - // TODO: Add loading spinner or something - if (result.isLoading || acceptMatch.isPending || rejectMatch.isPending) { - return null; + const user = useMemo(() => { + if (!result.data) return undefined; + if (result.data.length <= currentUserIndex) return undefined; + return result.data[currentUserIndex]; + }, [currentUserIndex, result.data]); + + if (result.isLoading || result.isFetching) { + return ( + +
+ +
+
+ ); } if (result.isError || !result.isSuccess) { - return
Error: {result.error?.message}
; + return ( + +
+

Whoops.

+ + Something went wrong, try refreshing the page + +
+
+ ); } - // TODO: Nicer message when there are no more users - if (result.data.length === 0) { - return
No more users
; + if (result.data.length === 0 || !user) { + return ( + +
+ Oops! You've swiped everyone away. Try not to look so surprised. +
+
+ ); } - const user = result.data[0]; - - // eslint-disable-next-line @typescript-eslint/no-unused-vars const openProfile = () => { setIsProfileOpen(true); }; @@ -34,12 +90,11 @@ const MatchCard = () => { const closeProfile = () => { setIsProfileOpen(false); }; - //Slice the description to 100 characters const truncatedDescription = user.description.slice(0, 100); return ( -
+ <> {!isProfileOpen && ( -
+
{
-
+
)} {isProfileOpen && } + + ); +}; + +interface MatchCardContainerProps { + children?: React.ReactNode; +} + +const MatchCardContainer = ({ + children, + ...props +}: MatchCardContainerProps) => { + return ( +
+
+ {children} +
); }; diff --git a/frontend/src/views/LoginForm.tsx b/frontend/src/views/LoginForm.tsx index d75983a..70bea64 100644 --- a/frontend/src/views/LoginForm.tsx +++ b/frontend/src/views/LoginForm.tsx @@ -3,6 +3,7 @@ import * as Yup from "yup"; import { EmailAtIcon, LockIcon } from "../assets"; import { Link, useLocation, useNavigate } from "react-router-dom"; import AuthService from "@/api/auth"; +import { CircularProgress } from "@mui/material"; const LoginSchema = Yup.object({ email: Yup.string().required("Required").email("Must be valid email"), @@ -23,7 +24,9 @@ const LoginForm = () => { const { mutateAsync: login } = AuthService.useLogin(); const from = location?.state?.from?.pathname ?? "/"; - const handleSubmit = (values: LoginValues) => { + const handleSubmit = ( + values: LoginValues /*helpers: FormikHelpers*/ + ) => { return login(values, { onSuccess: () => { navigate(from, { replace: true }); @@ -61,7 +64,7 @@ const LoginForm = () => {
@@ -76,14 +79,18 @@ const LoginForm = () => { Forgot Password ? diff --git a/frontend/src/views/MatchesPage.tsx b/frontend/src/views/MatchesPage.tsx index 5187bbf..42f2e61 100644 --- a/frontend/src/views/MatchesPage.tsx +++ b/frontend/src/views/MatchesPage.tsx @@ -8,8 +8,8 @@ const MatchesPage = () => { if (query.isLoading) { return ( -
- +
+
); diff --git a/frontend/src/views/RegisterForm.tsx b/frontend/src/views/RegisterForm.tsx index e6bc357..ad12bbe 100644 --- a/frontend/src/views/RegisterForm.tsx +++ b/frontend/src/views/RegisterForm.tsx @@ -7,6 +7,7 @@ import { EmailAtIcon, LockIcon } from "../assets"; import { Link, useNavigate } from "react-router-dom"; import AuthService from "@/api/auth"; import DateRangeIcon from "@mui/icons-material/DateRange"; +import { CircularProgress } from "@mui/material"; const RegisterSchema = Yup.object({ email: Yup.string().required("Required").email("Must be a valid email"), password: Yup.string() @@ -220,7 +221,11 @@ const RegisterForm = () => { type="submit" className="block w-full bg-red-600 transition duration-300 ease-in-out hover:bg-red-800 mt-4 py-3 rounded-2xl text-white font-semibold" > - Register + {!isSubmitting ? ( + "Login" + ) : ( + + )}