diff --git a/src/assets/icons/confirm.svg b/src/assets/icons/confirm.svg new file mode 100644 index 0000000..33c90dd --- /dev/null +++ b/src/assets/icons/confirm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/hooks/useGetInfiniteActivityPostList.ts b/src/hooks/useGetInfiniteActivityPostList.ts new file mode 100644 index 0000000..9c15ba6 --- /dev/null +++ b/src/hooks/useGetInfiniteActivityPostList.ts @@ -0,0 +1,27 @@ +import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; +import { getActivityPostList, type ActivityPostType } from "../lib/activity"; + +export const useInfiniteActivityPosts = (type: ActivityPostType, size = 20) => { + return useSuspenseInfiniteQuery({ + queryKey: ["posts", "activity", type], + + queryFn: ({ pageParam }) => + getActivityPostList(type, { + pageParam, + size, + }), + + initialPageParam: undefined, + + getNextPageParam: lastPage => { + if (!lastPage.data.hasNext) return undefined; + + return type === "bookmark" + ? lastPage.data.lastBookmarkId + : lastPage.data.lastReadPostId; + }, + + select: res => res.pages, + gcTime: 1000 * 60 * 5, + }); +}; diff --git a/src/hooks/useGetInfiniteBookmarkList.ts b/src/hooks/useGetInfiniteBookmarkList.ts deleted file mode 100644 index 0fdd984..0000000 --- a/src/hooks/useGetInfiniteBookmarkList.ts +++ /dev/null @@ -1,20 +0,0 @@ -//북마크 리스트 무한스크롤 -import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; -import { getBookmarkList } from "../lib/activity"; - -export const useInfiniteBookmarkPosts = (size = 20) => { - return useSuspenseInfiniteQuery({ - queryKey: ["posts", "bookmarks"], - queryFn: ({ pageParam }) => - getBookmarkList({ - lastBookmarkId: pageParam, - size, - }), - initialPageParam: undefined, - getNextPageParam: lastPage => { - if (!lastPage.data.hasNext) return undefined; - return lastPage.data.lastBookmarkId; - }, - select: res => res.pages, - }); -}; diff --git a/src/hooks/useGetInfiniteCompaniesList.ts b/src/hooks/useGetInfiniteCompaniesList.ts index 50c2ae6..99a3fe1 100644 --- a/src/hooks/useGetInfiniteCompaniesList.ts +++ b/src/hooks/useGetInfiniteCompaniesList.ts @@ -31,5 +31,7 @@ export const useInfiniteCompaniesPosts = ({ }, select: res => res.pages, + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 10, }); }; diff --git a/src/hooks/useGetInfinitePostList.ts b/src/hooks/useGetInfinitePostList.ts index c23c53b..a30ecf4 100644 --- a/src/hooks/useGetInfinitePostList.ts +++ b/src/hooks/useGetInfinitePostList.ts @@ -36,6 +36,12 @@ export const useInfinitePosts = ({ getNextPageParam: lastPage => { if (!lastPage.data?.hasNext) return undefined; + if (sortBy === "POPULAR") { + return { + lastViewCount: lastPage.data.lastViewCount, + lastPostId: lastPage.data.lastPostId, + }; + } return { lastPublishedAt: lastPage.data.lastPublishedAt, @@ -44,5 +50,7 @@ export const useInfinitePosts = ({ }, select: res => res.pages, + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 10, }); }; diff --git a/src/lib/activity.ts b/src/lib/activity.ts index 00eb392..b6e2f02 100644 --- a/src/lib/activity.ts +++ b/src/lib/activity.ts @@ -2,13 +2,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import api from "./api"; -import type { - ReadPostType, - UseInfiniteBookmarkPostsParams, -} from "../types/post"; +import type { ReadPostType } from "../types/post"; import { updateBookmarkState } from "../utils/queryUpdata"; -//1. 북마크 추가 +//무한스크롤 +export type ActivityPostType = "bookmark" | "read"; + +// 북마크 추가 export const postBookmark = async (postId: number) => { const { data } = await api.post("/api/v1/activities/bookmarks", { postId }); return data; @@ -20,11 +20,14 @@ export const usePostBookmark = () => { return useMutation({ mutationFn: (postId: number) => postBookmark(postId), onMutate: async postId => { + //post로 시작하는 query모두 취소 await queryClient.cancelQueries({ queryKey: ["posts"] }); + //복구위한 데이터 백업 const previousQueries = queryClient.getQueriesData({ queryKey: ["posts"], }); + queryClient.setQueriesData( { queryKey: ["posts"], exact: false }, (old: any) => updateBookmarkState(old, postId, true), @@ -33,10 +36,11 @@ export const usePostBookmark = () => { return { previousQueries }; }, onError: () => queryClient.invalidateQueries({ queryKey: ["posts"] }), - onSettled: () => queryClient.invalidateQueries({ queryKey: ["posts"] }), + // onSettled: () => queryClient.invalidateQueries({ queryKey: ["posts"] }), }); }; -//1. 북마크 제거 + +//북마크 제거 export const deleteBookmark = async (postId: number) => { const { data } = await api.delete("/api/v1/activities/bookmarks", { data: { postId }, @@ -63,14 +67,33 @@ export const useDeleteBookmark = () => { return { previousQueries }; }, onError: () => queryClient.invalidateQueries({ queryKey: ["posts"] }), - onSettled: () => queryClient.invalidateQueries({ queryKey: ["posts"] }), + // onSettled: () => queryClient.invalidateQueries({ queryKey: ["posts"] }), }); }; -//북마크 목록 조회 -export const getBookmarkList = async ( - params: UseInfiniteBookmarkPostsParams, + +export type UseInfiniteActivityPostsParams = { + lastBookmarkId?: number; + lastReadPostId?: number; + size: number; +}; + +//읽은게시글 + 북마크 목록 통합 +export const getActivityPostList = async ( + type: ActivityPostType, + { pageParam, size }: { pageParam?: number; size: number }, ) => { - const { data } = await api.get("/api/v1/activities/bookmarks", { params }); + const url = + type === "bookmark" + ? "/api/v1/activities/bookmarks" + : "/api/v1/activities/read-posts"; + + const params = { + size, + ...(pageParam !== undefined && { + [type === "bookmark" ? "lastBookmarkId" : "lastReadPostId"]: pageParam, + }), + }; + const { data } = await api.get(url, { params }); return data; }; @@ -88,7 +111,7 @@ export const usePostReadPost = () => { onSuccess: () => { console.log("읽은 게시글 저장"); queryClient.invalidateQueries({ - queryKey: ["posts"], + queryKey: ["posts", "read"], }); }, onError: err => console.log(err), diff --git a/src/lib/post.ts b/src/lib/post.ts index 59b3126..e89fba6 100644 --- a/src/lib/post.ts +++ b/src/lib/post.ts @@ -8,6 +8,7 @@ export interface GetPostListParams { size?: number; lastPublishedAt?: string; lastPostId?: number; + lastViewCount?: number; } export const getPostList = async ( params: GetPostListParams, diff --git a/src/pages/Login/LoginPage.tsx b/src/pages/Login/LoginPage.tsx index 1f16d86..4dab141 100644 --- a/src/pages/Login/LoginPage.tsx +++ b/src/pages/Login/LoginPage.tsx @@ -1,3 +1,6 @@ +import Apple from "@/assets/images/apple.png"; +import Kakao from "@/assets/images/kakao.png"; + export const LoginPage = () => { const handleKakaoLogin = () => { window.location.href = @@ -21,19 +24,11 @@ export const LoginPage = () => { className="w-80 bg-kakao h-13 text-black rounded-xl body-r-16 flex gap-2 items-center justify-center cursor-pointer" onClick={handleKakaoLogin} > - kakao login + kakao login 카카오 로그인 diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index f08e91e..d131547 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -1,12 +1,11 @@ import { TabSelectList } from "./components/TabSelectList"; import { CompanyFilterList } from "./components/CompanyFilterList"; import { PostCardList } from "./components/PostCardList"; -import { Suspense, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import { useCompanyStore } from "../../store/uesCompanyStore"; import { useGetCompany } from "../../lib/company"; import { usePostRecommendPostList } from "../../lib/recommendation"; import { TAB_MAP } from "../../constants/tab"; -import { Loading } from "../../shared/Loading"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useDebounce } from "../../hooks/useDebouce"; import { SearchPostList } from "./components/SearchPostList"; @@ -15,10 +14,11 @@ import { toast } from "react-toastify"; import Alert from "@/assets/icons/alert2.svg"; import { SkeletonList } from "../../shared/SkeletonList"; import { InterestPage } from "./components/InterestPage"; +import { Loading } from "../../shared/Loading"; export const HomePage = () => { const [selectedTab, setSelectedTab] = useState(0); const [modal, setModal] = useState(false); - const { companies, toggleCompany } = useCompanyStore(); + const { companies, toggleCompany, resetCompanies } = useCompanyStore(); const { data: companyData } = useGetCompany(); const { mutate: postRecommendList, isPending: isRefreshing } = @@ -26,40 +26,54 @@ export const HomePage = () => { const maxCompany = companyData?.companies.slice(0, 8) ?? []; - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const searchQuery = searchParams.get("search"); - // console.log(searchQuery); const debouncedInput = useDebounce(searchQuery, 200); + const isSearching = debouncedInput && debouncedInput.trim() !== ""; const { user } = useUserStore(); const isLogin = !!user?.accessToken; const navigate = useNavigate(); + const handleTabChange = (tab: number) => { if (tab === 1 && !isLogin) { + // resetCompanies(); toast.info("로그인이 필요한 서비스입니다.", { icon: login으로 이동, }); + navigate("/login"); + return; } + setSearchParams({}); setSelectedTab(tab); }; + useEffect(() => { + //store비우긴 + return () => { + resetCompanies(); + }; + }, []); + return (
setModal(false)}> - {/* */} + {debouncedInput && debouncedInput.trim() !== "" ? ( - }> - - + <> + }> + + + ) : ( <> - - {selectedTab === 0 && ( <> { )} {/* 나와맞는 게시글 */} {selectedTab === 1 && isLogin && ( - }> + }> - {isRefreshing ? ( - // 반복되는 Skeleton UI는 별도 컴포넌트로 빼면 깔끔합니다. - ) : ( - - )} + {isRefreshing ? : } ); }; diff --git a/src/pages/home/components/TabSelectList.tsx b/src/pages/home/components/TabSelectList.tsx index f15f0a4..0d48a92 100644 --- a/src/pages/home/components/TabSelectList.tsx +++ b/src/pages/home/components/TabSelectList.tsx @@ -3,7 +3,7 @@ import clsx from "clsx"; interface TabSelectList { className?: string; - selected?: number; + selected?: number | null; onChange: (idx: number) => void; tagList: string[]; } diff --git a/src/pages/mypage/AskPage.tsx b/src/pages/mypage/AskPage.tsx index 886f59b..aab4e84 100644 --- a/src/pages/mypage/AskPage.tsx +++ b/src/pages/mypage/AskPage.tsx @@ -23,7 +23,7 @@ export const AskPage = () => { const [confirmModal, setIsConfirmModal] = useState(false); const activeBtn = - title.length > 2 && content.length > 2 && askContent.length > 0; //모두 true여야함, + 다 true면 마지막꺼 + title.length > 2 && content.length > 2 && askContent.length > 0; const handleAsk = (e: React.MouseEvent, value: string) => { e.stopPropagation(); @@ -71,7 +71,7 @@ export const AskPage = () => { {ASK_MAP.map(askItem => { return (

handleAsk(e, askItem)} > {askItem} diff --git a/src/pages/mypage/EditInterestPage.tsx b/src/pages/mypage/EditInterestPage.tsx index f383a60..0f9e1fd 100644 --- a/src/pages/mypage/EditInterestPage.tsx +++ b/src/pages/mypage/EditInterestPage.tsx @@ -105,7 +105,7 @@ export const EditInterestPage = () => {

diff --git a/src/pages/onboarding/components/OnboardingHeader.tsx b/src/pages/onboarding/components/OnboardingHeader.tsx index 5a08998..f7afe96 100644 --- a/src/pages/onboarding/components/OnboardingHeader.tsx +++ b/src/pages/onboarding/components/OnboardingHeader.tsx @@ -36,7 +36,7 @@ export const OnboardingHeader = ({ basic = true }: OnboardingHeaderProps) => { basic ? "bg-assistive" : "bg-blue-500", )} > -

1

+

2

( if (!id || !url) return; try { + if (!isLogin) return; await readPostMutation.mutateAsync({ postId: id, readAt: new Date().toISOString(), diff --git a/src/shared/SystemHeader.tsx b/src/shared/SystemHeader.tsx index e9a8757..8d41c1e 100644 --- a/src/shared/SystemHeader.tsx +++ b/src/shared/SystemHeader.tsx @@ -3,7 +3,7 @@ import Search from "@/assets/icons/search.svg"; import User from "@/assets/images/user.png"; import Logo from "@/assets/images/logo.png"; import { Button } from "./button/Button"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useEffect, useRef, useState } from "react"; import { MYPAGE_NAV } from "../constants/mypage"; import useUserStore from "../store/useUserStore"; @@ -11,6 +11,7 @@ import { postLogout } from "../lib/auth"; import { useGetMyProfile } from "../lib/my"; import { toast } from "react-toastify"; import Alert from "@/assets/icons/alert2.svg"; +import Logout from "@/assets/icons/confirm.svg"; export const SystemHeader = () => { const navigate = useNavigate(); @@ -21,10 +22,15 @@ export const SystemHeader = () => { const isLogin = !!user?.accessToken; const { data } = useGetMyProfile(isLogin); + const [searchParams] = useSearchParams(); + const searchQuery = searchParams.get("search") ?? ""; const handleLogout = async () => { try { await postLogout(); + toast.info(`로그아웃 되었습니다.`, { + icon: logout, + }); } catch (error) { console.error("로그아웃 실패:", error); } finally { @@ -50,23 +56,29 @@ export const SystemHeader = () => { }; document.addEventListener("click", handleClick); + return () => { + //cleanup + document.removeEventListener("click", handleClick); + }; }, []); const [input, setInput] = useState(""); useEffect(() => { + if (!input) return; if (input) { navigate(`/?search=${input}`); - } else { - navigate("/"); } }, [input, navigate]); + useEffect(() => { + if (userModal) { + setInput(""); + } + }, [userModal]); + return (

-
+
로고 { type="text" placeholder="검색어 또는 태그명 입력" className="w-full px-3 py-2 focus:outline-none outline-none" - value={input} + value={searchQuery} onChange={e => setInput(e.target.value)} />
@@ -100,9 +112,10 @@ export const SystemHeader = () => { src={isLogin ? data?.profileImage : User} alt="mypage" className="size-10 cursor-pointer rounded-full" - onClick={() => { + onClick={e => { + e.stopPropagation(); if (!isLogin) { - toast.info(`로그인이 sss필요한 서비스입니다.`, { + toast.info(`로그인이 필요한 서비스입니다.`, { icon: login으로 이동, }); navigate("/login"); @@ -113,7 +126,11 @@ export const SystemHeader = () => { />
{userModal && ( -
+
e.stopPropagation()} + className=" z-50 absolute top-15 shadow-ds100s right-0 w-43 rounded-2xl bg-white border border-bgNormal cursor-pointer" + > {MYPAGE_NAV.map(item => { return (
handleNavClick(item)}> diff --git a/src/types/post.ts b/src/types/post.ts index 5185150..74e95e1 100644 --- a/src/types/post.ts +++ b/src/types/post.ts @@ -45,11 +45,7 @@ export type PageParamType = { lastPostId?: number; }; -// 북마크 -export type UseInfiniteBookmarkPostsParams = { - lastBookmarkId?: number; - size: number; -}; +// 북마크+읽은게시글 export type PostListBookmarkResponse = { bookmarkId: number; @@ -75,3 +71,19 @@ export type ReadPostType = { readAt: string; readDurationSeconds: number; }; + +export interface ActivityPostResponseDTO { + readPost: number; // bookmark,read + postId: number; + title: string; + shortSummary: string; + url: string; + companyName: string; + logoUrl: string; + thumbnailUrl: string; + publishedAt: string; + viewCount: number; + keywords?: string[]; + isBookmarked: boolean; + readAt?: string; // read 전용 +} diff --git a/src/utils/queryUpdata.ts b/src/utils/queryUpdata.ts index ac33ce8..5f1fccd 100644 --- a/src/utils/queryUpdata.ts +++ b/src/utils/queryUpdata.ts @@ -1,13 +1,71 @@ -//낙관적 업데이트 export const updateBookmarkState = ( old: any, postId: number, isBookmarked: boolean, ) => { - // console.log("업뎃데이터", old); if (!old) return old; - //데이터 구조에 따른 분기 + // 무한 스크롤 + if (old.pages && Array.isArray(old.pages)) { + return { + ...old, + pages: old.pages.map((page: any) => { + const dataKey = page.data?.posts + ? "posts" + : page.data?.readPosts + ? "readPosts" + : page.data?.bookmarks + ? "bookmarks" + : null; + + if (!dataKey || !page.data[dataKey]) return page; + + // 북마크 리스트 filter + // 나머지는 상태만 업데이트 + if (dataKey === "bookmarks" && !isBookmarked) { + return { + ...page, + data: { + ...page.data, + [dataKey]: page.data[dataKey].filter( + (post: any) => post.id !== postId && post.postId !== postId, + ), + }, + }; + } + return { + ...page, + data: { + ...page.data, + [dataKey]: page.data[dataKey].map((post: any) => + post.id === postId || post.postId === postId + ? { ...post, isBookmarked } + : post, + ), + }, + }; + }), + }; + } + + // 2. 추천 게시글 + const isRec = old.recommendations || (old.data && old.data.recommendations); + if (isRec) { + const isRoot = !!old.recommendations; + const target = isRoot ? old : old.data; + + const updated = { + ...target, + recommendations: target.recommendations.map((post: any) => + post.postId === postId || post.id === postId + ? { ...post, isBookmarked } + : post, + ), + }; + return isRoot ? updated : { ...old, data: updated }; + } + + // 3. 일반 배열 구조 또는 data 속성 내 배열 구조 if (Array.isArray(old)) { return old.map((post: any) => post.id === postId || post.postId === postId @@ -15,6 +73,7 @@ export const updateBookmarkState = ( : post, ); } + if (old.data && Array.isArray(old.data)) { return { ...old, @@ -25,46 +84,6 @@ export const updateBookmarkState = ( ), }; } - if (old.pages) { - return { - ...old, - pages: old.pages.map((page: any) => ({ - ...page, - data: { - ...page.data, - posts: page.data.posts.map((post: any) => - post.id === postId || post.postId === postId - ? { ...post, isBookmarked } - : post, - ), - }, - })), - }; - } - if (old.recommendations) { - return { - ...old, - recommendations: old.recommendations.map((post: any) => - post.postId === postId || post.id === postId - ? { ...post, isBookmarked } - : post, - ), - }; - } - - if (old.data && old.data.recommendations) { - return { - ...old, - data: { - ...old.data, - recommendations: old.data.recommendations.map((post: any) => - post.postId === postId || post.id === postId - ? { ...post, isBookmarked } - : post, - ), - }, - }; - } return old; };