diff --git a/src/domains/mypage/components/DeleteAllModal.tsx b/src/domains/mypage/components/DeleteAllModal.tsx index c7720a56..44e16dca 100644 --- a/src/domains/mypage/components/DeleteAllModal.tsx +++ b/src/domains/mypage/components/DeleteAllModal.tsx @@ -2,6 +2,7 @@ import ConfirmModal from '@/shared/components/modal-pop/ConfirmModal'; import { Dispatch, SetStateAction } from 'react'; import useFetchMyBar from '../api/fetchMyBar'; import useFetchAlarm from '../api/fetchAlarm'; +import { useToast } from '@/shared/hook/useToast'; interface Props { open: boolean; @@ -11,16 +12,23 @@ interface Props { } function DeleteAllModal({ open, onClose, setIsModal, type }: Props) { + const { toastSuccess } = useToast(); const { deleteMyBar } = useFetchMyBar(); const { deleteAlarm } = useFetchAlarm(); const handleBarDelete = () => { deleteMyBar.mutate(undefined, { - onSuccess: () => setIsModal(false), + onSuccess: () => { + toastSuccess('성공적으로 삭제 되었습니다.'); + setIsModal(false); + }, }); }; const handleAlarmDelete = () => { deleteAlarm.mutate(undefined, { - onSuccess: () => setIsModal(false), + onSuccess: () => { + setIsModal(false); + toastSuccess('성공적으로 삭제 되었습니다.'); + }, }); }; diff --git a/src/domains/mypage/components/EditNickName.tsx b/src/domains/mypage/components/EditNickName.tsx index dcee62c8..c355c8bd 100644 --- a/src/domains/mypage/components/EditNickName.tsx +++ b/src/domains/mypage/components/EditNickName.tsx @@ -80,7 +80,7 @@ function EditNickName({ value={editNickName} className="w-full" /> - 전 닉네임으로 돌아가기 + 초기화 ); diff --git a/src/domains/mypage/components/pages/my-alarm/MyAlarm.tsx b/src/domains/mypage/components/pages/my-alarm/MyAlarm.tsx index 0124b1ee..3b95536f 100644 --- a/src/domains/mypage/components/pages/my-alarm/MyAlarm.tsx +++ b/src/domains/mypage/components/pages/my-alarm/MyAlarm.tsx @@ -7,6 +7,7 @@ import Link from 'next/link'; import useFetchAlarm from '@/domains/mypage/api/fetchAlarm'; import { useQuery } from '@tanstack/react-query'; import DeleteAllModal from '../../DeleteAllModal'; +import { useToast } from '@/shared/hook/useToast'; interface MyAlarm { createdAt: Date; @@ -21,6 +22,7 @@ interface MyAlarm { } function MyAlarm() { + const { toastInfo } = useToast(); const [isModal, setIsModal] = useState(false); const { fetchAlarm } = useFetchAlarm(); const { data } = useQuery({ @@ -29,6 +31,10 @@ function MyAlarm() { }); const handleDelete = () => { + if (data.items.length == 0) { + toastInfo('아직 알림이 없습니다.'); + return; + } setIsModal(!isModal); }; diff --git a/src/domains/mypage/components/pages/my-bar/MyBar.tsx b/src/domains/mypage/components/pages/my-bar/MyBar.tsx index 9ebd921d..521cfa3a 100644 --- a/src/domains/mypage/components/pages/my-bar/MyBar.tsx +++ b/src/domains/mypage/components/pages/my-bar/MyBar.tsx @@ -8,6 +8,7 @@ import { useState } from 'react'; import DeleteAllModal from '../../DeleteAllModal'; import useFetchMyBar from '@/domains/mypage/api/fetchMyBar'; import { useQuery } from '@tanstack/react-query'; +import { useToast } from '@/shared/hook/useToast'; interface MyCocktail { cocktailId: number; @@ -19,6 +20,7 @@ interface MyCocktail { } function MyBar() { + const { toastInfo } = useToast(); const [isModal, setIsModal] = useState(false); const { fetchMyBar } = useFetchMyBar(); const { data } = useQuery({ @@ -28,6 +30,10 @@ function MyBar() { }); const handleDelete = () => { + if (data.items.length == 0) { + toastInfo('저장한 칵테일이 없습니다.'); + return; + } setIsModal(!isModal); }; diff --git a/src/domains/recipe/api/CocktailSearch.tsx b/src/domains/recipe/api/CocktailSearch.tsx deleted file mode 100644 index aacd03fc..00000000 --- a/src/domains/recipe/api/CocktailSearch.tsx +++ /dev/null @@ -1,58 +0,0 @@ -'use client'; -import { getApi } from '@/app/api/config/appConfig'; -import { Cocktail } from '../types/types'; -import { Dispatch, SetStateAction, useEffect, useState } from 'react'; - -interface Props { - setData: Dispatch>; - setNoResults: Dispatch>; -} - -function CocktailSearch({ setData, setNoResults }: Props) { - const [alcoholStrengths, setAlcoholStrengths] = useState([]); - const [cocktailTypes, setCocktailTypes] = useState([]); - const [alcoholBaseTypes, setAlcoholBaseTypes] = useState([]); - - const searchApi = async (v?: string) => { - const keyword = v?.trim() ?? ''; - const body = { - keyword, - alcoholStrengths, - cocktailTypes, - alcoholBaseTypes, - page: 0, - size: 100, - }; - - if (!keyword && !alcoholStrengths.length && !cocktailTypes.length && !alcoholBaseTypes.length) { - setData([]); - setNoResults(false); - return null; - } - - const res = await fetch(`${getApi}/cocktails/search`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const json = await res.json(); - - setData(json.data); - setNoResults(json.data.length === 0); - }; - - useEffect(() => { - searchApi(); - }, [alcoholStrengths, cocktailTypes, alcoholBaseTypes]); - - return { - searchApi, - setAlcoholBaseTypes, - setAlcoholStrengths, - setCocktailTypes, - alcoholBaseTypes, - alcoholStrengths, - cocktailTypes, - }; -} -export default CocktailSearch; diff --git a/src/domains/recipe/api/RecipeFetch.tsx b/src/domains/recipe/api/RecipeFetch.tsx deleted file mode 100644 index 39dbe35d..00000000 --- a/src/domains/recipe/api/RecipeFetch.tsx +++ /dev/null @@ -1,74 +0,0 @@ -'use client'; - -import { getApi } from '@/app/api/config/appConfig'; -import { Cocktail } from '../types/types'; -import { Dispatch, SetStateAction, useCallback } from 'react'; -import { useAuthStore } from '@/domains/shared/store/auth'; - -interface Props { - setData: React.Dispatch>; - lastId: number | null; - setLastId: Dispatch>; - hasNextPage: boolean; - setHasNextPage: Dispatch>; - SIZE?: number; -} - -// api/cocktais fetch용 -export const RecipeFetch = ({ - setData, - lastId, - setLastId, - hasNextPage, - setHasNextPage, - SIZE = 20, -}: Props) => { - const user = useAuthStore((state) => state.user); - const fetchData = useCallback(async () => { - // 쿼리파라미터에 값 넣기 - if (!hasNextPage) return; - const url = new URL(`${getApi}/cocktails`); - url.searchParams.set('size', String(SIZE)); - if (typeof lastId === 'number') { - url.searchParams.set('lastId', String(lastId)); - } - url.searchParams.set('LastValue', String(lastId)); - - const recipeRes = await fetch(url.toString(), { - method: 'GET', - }); - if (!recipeRes.ok) throw new Error('데이터 요청 실패'); - const recipeJson = await recipeRes.json(); - const list: Cocktail[] = recipeJson.data ?? []; - - if (user) { - const keepRes = await fetch(`${getApi}/me/bar`, { - method: 'GET', - credentials: 'include', - }); - const bars = keepRes.ok ? ((await keepRes.json()).data ?? []) : []; - const favoriteIds = new Set(bars.map((m: { cocktailId: number }) => m.cocktailId)); - const merged = list.map((item) => ({ - ...item, - isFavorited: favoriteIds.has(item.cocktailId), - })); - setData((prev) => - Array.from( - new Map([...prev, ...merged].map((i) => [i.cocktailId, i])).values() - ) - ); - } else { - setData((prev) => - Array.from( - new Map([...prev, ...list].map((i) => [i.cocktailId, i])).values() - ) - ); - } - - if (list.length > 0) { - setLastId(list[list.length - 1].cocktailId); - } - setHasNextPage(list.length === SIZE); - }, [hasNextPage, lastId, setData, setLastId, setHasNextPage, SIZE]); - return { fetchData }; -}; diff --git a/src/domains/recipe/api/fetchRecipe.ts b/src/domains/recipe/api/fetchRecipe.ts new file mode 100644 index 00000000..91d20925 --- /dev/null +++ b/src/domains/recipe/api/fetchRecipe.ts @@ -0,0 +1,173 @@ +import { getApi } from '@/app/api/config/appConfig'; +import { useAuthStore } from '@/domains/shared/store/auth'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { Cocktail, Sort } from '../types/types'; + +interface CocktailResponse { + data: Cocktail[]; +} + +interface KeepResponse { + data: Array<{ cocktailId: number }>; +} + +interface SearchFilters { + keyword?: string; + alcoholStrengths: string[]; + cocktailTypes: string[]; + alcoholBaseTypes: string[]; +} + +interface CocktailFilter extends SearchFilters { + sortBy?: Sort; +} + +const fetchKeep = async (): Promise> => { + const res = await fetch(`${getApi}/me/bar`, { + method: 'GET', + credentials: 'include', + }); + + if (!res.ok) return new Set(); + + const json: KeepResponse = await res.json(); + const myKeep = json.data ?? []; + return new Set(myKeep.map((v: { cocktailId: number }) => v.cocktailId)); +}; + +const fetchRecipe = async ( + lastId: number | null, + size: number, + sortBy?: Sort +): Promise => { + const url = new URL(`${getApi}/cocktails`); + url.searchParams.set('size', String(size)); + if (lastId !== null) { + url.searchParams.set('lastId', String(lastId)); + url.searchParams.set('lastValue', String(lastId)); + } + + if (sortBy) { + url.searchParams.set('sortBy', String(sortBy)); + } + + const res = await fetch(url.toString(), { + method: 'GET', + }); + + if (!res.ok) throw new Error('레시피 패치 실패'); + + const json: CocktailResponse = await res.json(); + + return json.data ?? []; +}; + +const searchCocktails = async (filters: SearchFilters): Promise => { + const body = { + keyword: filters.keyword?.trim() ?? '', + alcoholStrengths: filters.alcoholStrengths, + cocktailTypes: filters.cocktailTypes, + alcoholBaseTypes: filters.alcoholBaseTypes, + page: 0, + size: 20, + }; + + const res = await fetch(`${getApi}/cocktails/search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) throw new Error('검색 POST 실패'); + + const json: CocktailResponse = await res.json(); + return json.data ?? []; +}; + +const hasActiveFilters = (filters: SearchFilters): boolean => { + return !!( + filters.keyword?.trim() || + filters.alcoholStrengths.length > 0 || + filters.cocktailTypes.length > 0 || + filters.alcoholBaseTypes.length > 0 + ); +}; + +export const useCocktailsInfiniteQuery = (size: number = 20, sortBy?: Sort) => { + const user = useAuthStore((state) => state.user); + return useInfiniteQuery({ + queryKey: ['cocktails', 'infinite', sortBy, size, user?.id], + queryFn: async ({ pageParam }) => { + const cocktails = await fetchRecipe(pageParam, size, sortBy); + + if (user) { + const keepId = await fetchKeep(); + return cocktails.map((item) => ({ + ...item, + isKeep: keepId.has(item.cocktailId), + })); + } + + return cocktails; + }, + getNextPageParam: (lastpage) => { + if (lastpage.length < size) return undefined; + return lastpage[lastpage.length - 1]?.cocktailId ?? undefined; + }, + initialPageParam: null as number | null, + }); +}; + +export const useCocktailsSearchQuery = (filters: SearchFilters) => { + const user = useAuthStore((state) => state.user); + const isActive = hasActiveFilters(filters); + + return useQuery({ + queryKey: ['cocktails', 'search', filters, user?.id], + queryFn: async () => { + const cocktails = await searchCocktails(filters); + if (user && cocktails.length > 0) { + const keepId = await fetchKeep(); + return cocktails.map((item) => ({ + ...item, + isKeep: keepId.has(item.cocktailId), + })); + } + return cocktails; + }, + enabled: isActive, + refetchOnMount: false, + }); +}; + +export const useCocktails = ( + filters: CocktailFilter, + infiniteScrollSize: number = 20, + sortBy?: Sort +) => { + const isSearchMode = hasActiveFilters(filters); + const infiniteQuery = useCocktailsInfiniteQuery(infiniteScrollSize, sortBy); + const searchQuery = useCocktailsSearchQuery(filters); + + if (isSearchMode) { + return { + data: searchQuery.data ?? [], + noResults: searchQuery.data?.length === 0, + isSearchMode: true, + fetchNextPage: undefined, + hasNextPage: false, + isFetchingNextPage: false, + }; + } + + const allCocktails = infiniteQuery.data?.pages.flatMap((page) => page) ?? []; + + return { + data: allCocktails, + noResults: false, + isSearchMode: false, + fetchNextPage: infiniteQuery.fetchNextPage, + hasNextPage: infiniteQuery.hasNextPage, + isFetchingNextPage: infiniteQuery.isFetchingNextPage, + }; +}; diff --git a/src/domains/recipe/api/fetchRecipeComment.ts b/src/domains/recipe/api/fetchRecipeComment.ts deleted file mode 100644 index 80f758a4..00000000 --- a/src/domains/recipe/api/fetchRecipeComment.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { getApi } from '@/app/api/config/appConfig'; -import { CommentType } from '@/domains/community/types/post'; - -export const getRecipeComment = async (cocktailId: number): Promise => { - try { - const res = await fetch(`${getApi}/cocktails/${cocktailId}/comments`, { - method: 'GET', - cache: 'no-store', // 캐시 비활성화 - }); - const data = await res.json(); - - //삭제된 댓글은 제외 - const filteredComments = data.data.filter( - (comment: CommentType) => comment.status !== 'DELETED' - ); - - return filteredComments; - } catch (err) { - console.error('해당 글의 댓글 조회 실패', err); - return null; - } -}; - -export async function updateComment( - postId: number, - commentId: number, - content: string -): Promise { - console.log(postId, typeof postId); - const response = await fetch(`${getApi}/cocktails/${postId}/comments/${commentId}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify({ content }), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error('서버 응답 에러:', errorText); - throw new Error(`댓글 수정 실패: ${response.status}`); - } -} - -export async function deleteRecipeComment(cocktailId: number, commentId: number): Promise { - const response = await fetch(`${getApi}/cocktails/${cocktailId}/comments/${commentId}`, { - method: 'DELETE', - credentials: 'include', - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error('서버 응답 에러:', errorText); - throw new Error(`댓글 수정 실패: ${response.status}`); - } -} diff --git a/src/domains/recipe/api/useRecipeComment.ts b/src/domains/recipe/api/useRecipeComment.ts index 3fa8e6d1..ce743361 100644 --- a/src/domains/recipe/api/useRecipeComment.ts +++ b/src/domains/recipe/api/useRecipeComment.ts @@ -1,102 +1,117 @@ -import { useState, useEffect, useCallback } from 'react'; import { getApi } from '@/app/api/config/appConfig'; -import { User } from '@/domains/shared/store/auth'; import { CommentType } from '@/domains/community/types/post'; -import { deleteRecipeComment, getRecipeComment, updateComment } from './fetchRecipeComment'; +import { useAuthStore } from '@/domains/shared/store/auth'; import { useToast } from '@/shared/hook/useToast'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -export function useRecipeComments(cocktailId: number, user: User | null) { - const [comments, setComments] = useState(null); - const [isEnd, setIsEnd] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [deleteTarget, setDeleteTarget] = useState<{ - commentId: number; - cocktailId: number; - } | null>(null); - const { toastError } = useToast(); +export const postRecipeComment = async (cocktailId: number, content: string) => { + const body = { + cocktailId, + content, + status: 'PUBLIC', + }; - const fetchData = useCallback(async () => { - const data = await getRecipeComment(cocktailId); - if (!data) return; - setComments(data); - setIsEnd(false); - }, [cocktailId]); + const res = await fetch(`${getApi}/cocktails/${cocktailId}/comments`, { + method: 'POST', + headers: { 'Content-type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(body), + }); - useEffect(() => { - fetchData(); - }, [fetchData]); + const text = await res.text(); + const data = JSON.parse(text); + return data; +}; - const handleUpdateComment = async (commentId: number, content: string) => { - if (!user) { - toastError('로그인이 필요합니다'); - return; - } - try { - await updateComment(cocktailId, commentId, content); - setComments((prev) => - prev - ? prev.map((comment) => - comment.commentId === commentId ? { ...comment, content } : comment - ) - : prev - ); - } catch (err) { - console.error(err); - toastError('댓글 수정 중 오류가 발생했습니다.'); - } - }; +export const getRecipeComment = async (cocktailId: number) => { + const res = await fetch(`${getApi}/cocktails/${cocktailId}/comments`, { + method: 'GET', + cache: 'no-store', + credentials: 'include', + }); + const data = await res.json(); + if (res.status === 401) return []; + if (!res.ok) throw new Error('댓글 조회 실패'); + const filteredComments = data.data.filter((comment: CommentType) => comment.status !== 'DELETED'); - const handleAskDeleteComment = (commentId: number) => { - setDeleteTarget({ commentId, cocktailId }); - }; + return filteredComments; +}; - const handleConfirmDelete = async () => { - if (!user) { - toastError('로그인이 필요합니다'); - return; - } - if (!deleteTarget) return; +export const updateRecipeComment = async (postId: number, commentId: number, content: string) => { + const res = await fetch(`${getApi}/cocktails/${postId}/comments/${commentId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ content }), + }); - try { - await deleteRecipeComment(deleteTarget.cocktailId, deleteTarget.commentId); - setComments((prev) => - prev ? prev.filter((c) => c.commentId !== deleteTarget.commentId) : prev - ); - } catch (err) { - console.error(err); - } finally { - setDeleteTarget(null); - } - }; + if (!res.ok) throw new Error('댓글 수정 실패'); +}; - const loadMoreComments = async (lastCommentId: number) => { - if (isEnd || isLoading) return; +export const deleteRecipeComment = async (cocktailId: number, commentId: number) => { + const res = await fetch(`${getApi}/cocktails/${cocktailId}/comments/${commentId}`, { + method: 'DELETE', + credentials: 'include', + }); + if (!res.ok) throw new Error('댓글 삭제 실패'); +}; - setIsLoading(true); - try { - const res = await fetch(`${getApi}/cocktails/${cocktailId}/comments?lastId=${lastCommentId}`); - const newComments = await res.json(); +export function useRecipeComment({ cocktailId }: { cocktailId: number }) { + const queryClient = useQueryClient(); + const user = useAuthStore((state) => state.user); + const { toastInfo, toastError } = useToast(); + + const { + data: comments = [], + refetch, + isLoading, + } = useQuery({ + queryKey: ['comments', cocktailId], + queryFn: () => getRecipeComment(cocktailId), + staleTime: 30_000, + }); - if (newComments.data.length === 0) { - setIsEnd(true); - } else { - setComments((prev) => [...(prev ?? []), ...newComments.data]); + const createMut = useMutation({ + mutationFn: (content: string) => { + if (!user?.id) { + toastInfo('로그인 후 이용 가능합니다.'); + return Promise.reject(new Error('unauth')); } - } finally { - setIsLoading(false); - } - }; + return postRecipeComment(cocktailId, content); + }, + onSuccess: () => refetch(), + onError: (e) => { + if (e.message !== 'unauth') { + toastInfo('댓글은 한개만 작성 가능합니다'); + } + }, + }); - return { - comments, - isEnd, - isLoading, - deleteTarget, - setDeleteTarget, - fetchData, - handleUpdateComment, - handleAskDeleteComment, - handleConfirmDelete, - loadMoreComments, - }; + const updateMut = useMutation({ + mutationFn: ({ commentId, content }: { commentId: number; content: string }) => + updateRecipeComment(cocktailId, commentId, content), + onSuccess: (_, vars) => { + queryClient.setQueryData( + ['comments', cocktailId], + (prev) => + prev?.map((c) => + c.commentId === vars.commentId ? { ...c, content: vars.content } : c + ) ?? prev + ); + }, + onError: () => toastError('수정 중 에러가 발생했습니다.'), + }); + + const deleteMut = useMutation({ + mutationFn: (commentId: number) => deleteRecipeComment(cocktailId, commentId), + onSuccess: (_res, commentId) => { + queryClient.setQueryData( + ['comments', cocktailId], + (prev) => prev?.filter((c) => c.commentId !== commentId) ?? prev + ); + }, + }); + return { createMut, updateMut, deleteMut, comments, refetch, user, isLoading }; } diff --git a/src/domains/recipe/api/useRecipeDetails.ts b/src/domains/recipe/api/useRecipeDetails.ts new file mode 100644 index 00000000..7f43cd93 --- /dev/null +++ b/src/domains/recipe/api/useRecipeDetails.ts @@ -0,0 +1,41 @@ +import { getApi } from '@/app/api/config/appConfig'; +import { useAuthStore } from '@/domains/shared/store/auth'; +import { useQuery } from '@tanstack/react-query'; + +const fetchKeep = async () => { + const res = await fetch(`${getApi}/me/bar`, { + method: 'GET', + credentials: 'include', + }); + + if (!res.ok) return new Set(); + + const json = await res.json(); + const mykeep = json.data; + + return new Set(mykeep.map((v: { cocktailId: number }) => v.cocktailId)); +}; + +const fetchRecipe = async (id: number) => { + const res = await fetch(`${getApi}/cocktails/${id}`, { + method: 'GET', + }); + + if (!res.ok) throw new Error('상세페이지 fetch 실패'); + const json = await res.json(); + return json.data ?? []; +}; + +export const useDetailRecipe = (id: number) => { + const user = useAuthStore((state) => state.user); + + return useQuery({ + queryKey: ['detail', id, user?.id], + queryFn: async () => { + const recipe = await fetchRecipe(id); + const keep = user ? await fetchKeep() : null; + const iskept = keep ? keep.has(Number(id)) : false; + return { recipe, iskept }; + }, + }); +}; diff --git a/src/domains/recipe/components/details/BackBtn.tsx b/src/domains/recipe/components/details/BackBtn.tsx index 069522a7..af3bc26b 100644 --- a/src/domains/recipe/components/details/BackBtn.tsx +++ b/src/domains/recipe/components/details/BackBtn.tsx @@ -1,20 +1,23 @@ 'use client'; -import { useRouter } from 'next/navigation'; import Back from '@/shared/assets/icons/back_36.svg'; +import { useSaveScroll } from '../../hook/useSaveScroll'; function BackButton() { - const router = useRouter(); + const { restoreAndGoBack } = useSaveScroll({ + storageKey: 'cocktail_list_scroll', + }); - const handleClick = () => { - const url = sessionStorage.getItem('saveUrl'); - if (!url) return; - router.push(url); - sessionStorage.removeItem('listScrollY'); + const handleBack = () => { + console.log('뒤로가기 클릭'); + console.log('저장된 스크롤:', sessionStorage.getItem('cocktail_list_scroll')); + console.log('저장된 URL:', sessionStorage.getItem('cocktail_list_scroll_url')); + console.log('복원 플래그:', sessionStorage.getItem('cocktail_list_scroll_restore')); + restoreAndGoBack(); }; return ( - ); diff --git a/src/domains/recipe/components/details/DetailMain.tsx b/src/domains/recipe/components/details/DetailMain.tsx index 3ee858ba..b0b11f90 100644 --- a/src/domains/recipe/components/details/DetailMain.tsx +++ b/src/domains/recipe/components/details/DetailMain.tsx @@ -7,79 +7,33 @@ import SsuryShake from '@/shared/assets/ssury/ssury_make.webp'; import SsuryDrink from '@/shared/assets/ssury/ssury_drink.webp'; import Image from 'next/image'; import DetailList from './DetailList'; -import { Suspense, useEffect, useState } from 'react'; -import { getApi } from '@/app/api/config/appConfig'; -import { useAuthStore } from '@/domains/shared/store/auth'; +import { Suspense } from 'react'; import SkeletonDetail from '../../skeleton/SkeletonDetail'; import RecipeComment from './RecipeComment'; - -interface Kept { - cocktailId: number; - id: number; - keptAt: Date; -} +import { useDetailRecipe } from '../../api/useRecipeDetails'; function DetailMain({ id }: { id: number }) { - const user = useAuthStore(); - const [cocktail, setCocktail] = useState(); - const [isKept, setIsKept] = useState(null); - - const fetchData = async () => { - const res = await fetch(`${getApi}/cocktails/${id}`); - const json = await res.json(); - if (!res.ok) throw new Error('데이터 요청 실패'); - setCocktail(json.data); - - if (!user) { - setIsKept(false); - return; - } else { - const keepRes = await fetch(`${getApi}/me/bar`, { - method: 'GET', - credentials: 'include', - }); - const keepjson = await keepRes.json(); - const keepIds = keepjson.data.map((a: Kept) => String(a.cocktailId)); - setIsKept(keepIds.includes(String(id))); - } - }; - - useEffect(() => { - fetchData(); - }, []); + const { data } = useDetailRecipe(id); - useEffect(() => { - window.scrollTo(0, 0); - return () => { - // 레시피 페이지로 돌아가지 않는 경우 (헤더 탭 클릭 등) - // 네비게이션 플래그를 제거하여 스크롤 복원 방지 - const currentPath = window.location.pathname; + if (!data?.recipe) return null; - // 디테일 페이지를 벗어나는 경우 - if (!currentPath.includes('/recipe')) { - sessionStorage.removeItem('cocktails_scroll_state_nav_flag'); - } - }; - }, []); - - if (!cocktail) return; const { - cocktailId, - cocktailImgUrl, cocktailName, cocktailNameKo, cocktailStory, + cocktailImgUrl, alcoholStrength, cocktailType, ingredient, recipe, - } = cocktail; + cocktailId, + } = data?.recipe; return ( }> -

${cocktailNameKo} 상세정보

+

{`${cocktailNameKo} 상세정보`}

- +
diff --git a/src/domains/recipe/components/details/DetailsHeader.tsx b/src/domains/recipe/components/details/DetailsHeader.tsx index d15c0533..96ccc60e 100644 --- a/src/domains/recipe/components/details/DetailsHeader.tsx +++ b/src/domains/recipe/components/details/DetailsHeader.tsx @@ -13,7 +13,7 @@ interface Meta { url: string; } -function DetailsHeader({ id, favor }: { id: number; favor: boolean | null }) { +function DetailsHeader({ id, favor }: { id: number; favor: boolean | undefined }) { const [isShare, setIsShare] = useState(false); const [meta, setMeta] = useState(null); diff --git a/src/domains/recipe/components/details/RecipeComment.tsx b/src/domains/recipe/components/details/RecipeComment.tsx index 901a43c4..4fa13285 100644 --- a/src/domains/recipe/components/details/RecipeComment.tsx +++ b/src/domains/recipe/components/details/RecipeComment.tsx @@ -1,11 +1,9 @@ import CommentHeader from '@/domains/shared/components/comment/CommentHeader'; import CommentList from '@/domains/shared/components/comment/CommentList'; -import { useAuthStore } from '@/domains/shared/store/auth'; -import { useShallow } from 'zustand/shallow'; import ConfirmModal from '@/shared/components/modal-pop/ConfirmModal'; -import { useRecipeComments } from '../../api/useRecipeComment'; -import { getApi } from '@/app/api/config/appConfig'; -import { useToast } from '@/shared/hook/useToast'; +import { useRecipeComment } from '../../api/useRecipeComment'; +import { useState } from 'react'; +import { CommentType } from '@/domains/community/types/post'; import { ParamValue } from 'next/dist/server/request/params'; interface Props { @@ -13,53 +11,37 @@ interface Props { } function RecipeComment({ cocktailId }: Props) { - const { user } = useAuthStore( - useShallow((state) => ({ - user: state.user, - })) - ); - - const { toastInfo } = useToast(); + const [deleteTarget, setDeleteTarget] = useState<{ + commentId: number; + cocktailId: number; + } | null>(null); - const postRecipeComment = async (cocktailId: number | ParamValue, content: string) => { - if (!user?.id) { - toastInfo('로그인 후 이용 가능합니다'); - return; - } - const body = { - cocktailId, - content: content, - }; + const { refetch, createMut, deleteMut, updateMut, user, comments, isLoading } = useRecipeComment({ + cocktailId, + }); - const res = await fetch(`${getApi}/cocktails/${cocktailId}/comments`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(body), - }); - - const text = await res.text(); - if (!res.ok) { - toastInfo('댓글은 한 개만 작성가능합니다'); - return; - } + const postRecipeComment = async ( + postId: number | ParamValue, + content: string + ): Promise => { + if (typeof postId !== 'number') return null; + await createMut.mutateAsync(content); + const referesh = await refetch(); + return referesh.data ?? null; + }; + const handleUpdateComment = (commentId: number, content: string) => + updateMut.mutateAsync({ commentId, content }); - const data = JSON.parse(text); - return data; + const handleConfirmDelete = async () => { + if (!deleteTarget) return; + await deleteMut.mutateAsync(deleteTarget.commentId); + setDeleteTarget(null); }; - const { - comments, - fetchData, - handleAskDeleteComment, - handleUpdateComment, - loadMoreComments, - isEnd, - isLoading, - deleteTarget, - handleConfirmDelete, - setDeleteTarget, - } = useRecipeComments(cocktailId, user); + const fetchData = () => refetch; + const loadMoreComments = () => {}; + const isEnd = true; + const handleAskDeleteComment = (commentId: number) => setDeleteTarget({ commentId, cocktailId }); return (
diff --git a/src/domains/recipe/components/main/Accordion.tsx b/src/domains/recipe/components/main/Accordion.tsx index 771c5a1b..286a782d 100644 --- a/src/domains/recipe/components/main/Accordion.tsx +++ b/src/domains/recipe/components/main/Accordion.tsx @@ -1,7 +1,7 @@ 'use client'; import SelectBox from '@/shared/components/select-box/SelectBox'; -import { Dispatch, SetStateAction, useEffect, useMemo } from 'react'; +import { Dispatch, SetStateAction, useEffect } from 'react'; import { useSearchParams, usePathname, useRouter } from 'next/navigation'; interface Props { @@ -93,13 +93,13 @@ function Accordion({ setAlcoholBaseTypes, setCocktailTypes, setAlcoholStrengths }; // URL 파라미터에서 현재 선택된 값 가져오기 아코디언 UI에 적용 - const currentValues = useMemo(() => { + const currentValues = () => { return { abv: getDisplayValue('abv', searchParams.get('abv')), base: getDisplayValue('base', searchParams.get('base')), glass: getDisplayValue('glass', searchParams.get('glass')), }; - }, [searchParams]); + }; const handleSelect = (id: string, value: string) => { const optionGroup = SELECT_OPTIONS.find((opt) => opt.id === id); @@ -140,7 +140,7 @@ function Accordion({ setAlcoholBaseTypes, setCocktailTypes, setAlcoholStrengths return (
    {SELECT_OPTIONS.map(({ id, option, title }) => { - const currentValue = currentValues[id as keyof typeof currentValues]; + const currentValue = currentValues()[id as keyof typeof currentValues]; return (
  • @@ -149,7 +149,7 @@ function Accordion({ setAlcoholBaseTypes, setCocktailTypes, setAlcoholStrengths title={title} id={id} groupKey="filter" - value={currentValue} // 현재 선택된 값 전달 + value={currentValue} onChange={(value) => handleSelect(id, value)} />
  • diff --git a/src/domains/recipe/components/main/CocktailFilter.tsx b/src/domains/recipe/components/main/CocktailFilter.tsx index 589823c6..209b94aa 100644 --- a/src/domains/recipe/components/main/CocktailFilter.tsx +++ b/src/domains/recipe/components/main/CocktailFilter.tsx @@ -1,47 +1,32 @@ -import { getApi } from '@/app/api/config/appConfig'; import SelectBox from '@/shared/components/select-box/SelectBox'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { Dispatch, SetStateAction } from 'react'; -import { Cocktail } from '../../types/types'; +import { useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; interface Props { - cocktailsEA: string; - setData: Dispatch>; + cocktailsEA: number; } -function CocktailFilter({ cocktailsEA, setData }: Props) { +function CocktailFilter({ cocktailsEA }: Props) { const sortMap = { 최신순: 'recent', 인기순: 'keeps', 댓글순: 'comments', }; - const searchParams = useSearchParams(); - const query = searchParams.get('sortBy'); + const queryClient = useQueryClient(); const router = useRouter(); const handleChange = async (selectTitle: string) => { - if (!query) return; - try { - const res = await fetch(`${getApi}/cocktails`); - const json = await res.json(); - setData(json.data); - } catch { - console.error(); - console.log(selectTitle); - } + const sortValue = sortMap[selectTitle as keyof typeof sortMap]; + queryClient.removeQueries({ + queryKey: ['cocktails', 'infinite'], + exact: false, + }); + router.push(`?sortBy=${sortValue}`); }; return (
    -

    {cocktailsEA}개

    - { - const sortValue = sortMap[value as keyof typeof sortMap]; - handleChange(value); - router.push(`?sortBy=${sortValue}`); - }} - /> +

    {cocktailsEA}개+

    +
    ); } diff --git a/src/domains/recipe/components/main/CocktailList.tsx b/src/domains/recipe/components/main/CocktailList.tsx index 0b2f7e63..3c43da7b 100644 --- a/src/domains/recipe/components/main/CocktailList.tsx +++ b/src/domains/recipe/components/main/CocktailList.tsx @@ -1,35 +1,25 @@ 'use client'; -import { useRef } from 'react'; + import Link from 'next/link'; -import { useIntersectionObserver } from '@/domains/shared/hook/useIntersectionObserver'; import { Cocktail } from '../../types/types'; import CocktailCard from '@/domains/shared/components/cocktail-card/CocktailCard'; -import { useScrollRestore } from '@/domains/shared/hook/useMemoScroll'; +import { useSaveScroll } from '../../hook/useSaveScroll'; interface Props { cocktails: Cocktail[]; - RecipeFetch?: (cursor?: string | undefined) => Promise; - hasNextPage: boolean; - lastId: number | null; } -function CocktailList({ cocktails, RecipeFetch, hasNextPage, lastId }: Props) { - const cocktailRef = useRef(null); - const onIntersect: IntersectionObserverCallback = ([entry]) => { - if (!RecipeFetch) return; - if (!lastId) return; - if (entry.isIntersecting && lastId > 1) { - RecipeFetch(); - } +function CocktailList({ cocktails }: Props) { + const { saveAndNavigate } = useSaveScroll({ + storageKey: 'cocktail_list_scroll', + }); + + const handleClick = (cocktailId: number) => (e: React.MouseEvent) => { + e.preventDefault(); + + saveAndNavigate(`/recipe/${cocktailId}`); }; - useIntersectionObserver(cocktailRef, onIntersect, hasNextPage); - const saveScroll = useScrollRestore({ - lastId, - fetchData: RecipeFetch!, - hasNextPage, - currentDataLength: cocktails.length, - }); return (
      {cocktails.map( - ({ - cocktailImgUrl, - cocktailId, - cocktailName, - cocktailNameKo, - alcoholStrength, - isFavorited, - }) => ( -
    • - + ( + { cocktailImgUrl, cocktailId, cocktailName, cocktailNameKo, alcoholStrength, isKeep }, + i + ) => ( +
    • + ) )} -
    ); } diff --git a/src/domains/recipe/components/main/CocktailSearchBar.tsx b/src/domains/recipe/components/main/CocktailSearchBar.tsx index 5850054e..96247815 100644 --- a/src/domains/recipe/components/main/CocktailSearchBar.tsx +++ b/src/domains/recipe/components/main/CocktailSearchBar.tsx @@ -1,16 +1,16 @@ import Input from '@/shared/components/Input-box/Input'; interface Props { - value: string; + keyword: string; onChange: (v: string) => void; } -function CocktailSearchBar({ value, onChange }: Props) { +function CocktailSearchBar({ keyword, onChange }: Props) { return ( onChange(e.target.value)} variant="search" className="w-full md:max-w-80" diff --git a/src/domains/recipe/components/main/Cocktails.tsx b/src/domains/recipe/components/main/Cocktails.tsx index a63dc93f..1a298611 100644 --- a/src/domains/recipe/components/main/Cocktails.tsx +++ b/src/domains/recipe/components/main/Cocktails.tsx @@ -1,76 +1,52 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import CocktailFilter from './CocktailFilter'; import CocktailList from './CocktailList'; -import { Cocktail } from '../../types/types'; import Accordion from './Accordion'; -import { RecipeFetch } from '../../api/RecipeFetch'; import CocktailSearchBar from './CocktailSearchBar'; -import useSearchControl from '../../hook/useSearchControl'; -import CocktailSearch from '../../api/CocktailSearch'; -import { useAuthStore } from '@/domains/shared/store/auth'; +import { useCocktails } from '../../api/fetchRecipe'; +import { useInView } from 'react-intersection-observer'; +import { debounce } from '@/shared/utills/debounce'; +import { useSearchParams } from 'next/navigation'; +import { Sort } from '../../types/types'; function Cocktails() { - const user = useAuthStore((state) => state.user); + const searchParams = useSearchParams(); + const sortBy = searchParams.get('sortBy') as Sort; + const [keyword, setKeyword] = useState(''); + const [input, setInput] = useState(''); - const [data, setData] = useState([]); - const [lastId, setLastId] = useState(null); - const [hasNextPage, setHasNextPage] = useState(true); + const [alcoholStrengths, setAlcoholStrengths] = useState([]); + const [alcoholBaseTypes, setAlcoholBaseTypes] = useState([]); + const [cocktailTypes, setCocktailTypes] = useState([]); - const { inputValue, keyword, isSearching, onInputChange, noResults, setNoResults } = - useSearchControl({ delay: 300, storageKey: 'cocktails_scoll_state' }); - const { fetchData } = RecipeFetch({ setData, lastId, setLastId, hasNextPage, setHasNextPage }); + const { data, fetchNextPage, hasNextPage, noResults, isSearchMode } = useCocktails( + { + keyword, + alcoholBaseTypes, + alcoholStrengths, + cocktailTypes, + }, + 20, + sortBy + ); - const { - searchApi, - setAlcoholBaseTypes, - setAlcoholStrengths, - setCocktailTypes, - alcoholBaseTypes, - cocktailTypes, - alcoholStrengths, - } = CocktailSearch({ - setData, - setNoResults, + const { ref, inView } = useInView({ + threshold: 0.1, }); - const countLabel = isSearching - ? hasNextPage - ? `검색결과 현재 ${data.length}+` - : `검색결과 총 ${data.length}` - : hasNextPage - ? `전체 ${data.length}+` - : `전체 ${data.length}`; - - // 초기 로드 시 검색어가 있으면 검색 실행 - // useEffect(() => { - // const readyForFirstLoad = !isSearching && hasNextPage && lastId == null && data.length === 0; - - // if (readyForFirstLoad) { - // fetchData(); - // } - // }, [hasNextPage, lastId]); - - // 검색어 변경 시 useEffect(() => { - if (isSearching && keyword.trim()) { - setLastId(null); - setHasNextPage(false); - searchApi(keyword.trim()); - } else if (!isSearching) { - // 검색어를 지웠을 때만 초기화 - setData([]); - setLastId(null); - setHasNextPage(true); + if (!isSearchMode && inView && hasNextPage) { + fetchNextPage?.(); } - }, [keyword, isSearching, alcoholBaseTypes, alcoholStrengths, cocktailTypes]); + }, [inView, hasNextPage, fetchNextPage]); - // 일반 fetch - useEffect(() => { - if (isSearching) return; - fetchData(); - }, [isSearching, alcoholBaseTypes, alcoholStrengths, cocktailTypes]); + const debounceKeyword = useMemo(() => debounce((v: string) => setKeyword(v), 300), []); + const handleSearch = (v: string) => { + setInput(v); + debounceKeyword(v); + }; return (
    @@ -80,23 +56,15 @@ function Cocktails() { setAlcoholStrengths={setAlcoholStrengths} setCocktailTypes={setCocktailTypes} /> - +
- +
- {isSearching && noResults ? ( -
검색결과가 없습니다.
- ) : ( - - )} + {noResults ?
검색 결과가 없습니다.
: }
+
); } diff --git a/src/domains/recipe/hook/useSaveScroll.ts b/src/domains/recipe/hook/useSaveScroll.ts new file mode 100644 index 00000000..8971021b --- /dev/null +++ b/src/domains/recipe/hook/useSaveScroll.ts @@ -0,0 +1,69 @@ +import { useRouter } from 'next/navigation'; +import { useEffect, useRef } from 'react'; + +interface Scroll { + storageKey?: string; + enabled?: boolean; + pageType?: 'list' | 'detail'; +} + +export const useSaveScroll = (opt: Scroll = {}) => { + const { storageKey = 'cocktail_scroll', enabled = true, pageType = 'list' } = opt; + const router = useRouter(); + const hasRestore = useRef(false); + + useEffect(() => { + if (pageType === 'detail') return; + + if (!enabled || hasRestore.current) return; + + const savedPosition = sessionStorage.getItem(storageKey); + const shouldRestore = sessionStorage.getItem(`${storageKey}_should_restore`); + + if (savedPosition && shouldRestore === 'true') { + const position = parseInt(savedPosition, 10); + + const restoreScroll = () => { + window.scrollTo(0, position); + hasRestore.current = true; + }; + + requestAnimationFrame(restoreScroll); + setTimeout(restoreScroll, 0); + setTimeout(restoreScroll, 100); + + sessionStorage.removeItem(`${storageKey}_should_restore`); + } + }, [storageKey, enabled, pageType]); + + const saveScroll = () => { + if (!enabled) return; + const currentScroll = window.scrollY; + sessionStorage.setItem(storageKey, currentScroll.toString()); + }; + + // 상세 페이지로 이동 (스크롤 위치만 저장, 복원 플래그는 설정 안함) + const saveAndNavigate = (href: string) => { + saveScroll(); + sessionStorage.setItem(`${storageKey}_url`, location.href); + router.push(href); + }; + + // 뒤로가기 (복원 플래그 설정) + const restoreAndGoBack = () => { + const saveUrl = sessionStorage.getItem(`${storageKey}_url`); + + if (!saveUrl) return; + + // 뒤로가기할 때만 복원 플래그 설정 + sessionStorage.setItem(`${storageKey}_should_restore`, 'true'); + + router.replace(saveUrl, { scroll: false }); + }; + + return { + saveScroll, + saveAndNavigate, + restoreAndGoBack, + }; +}; diff --git a/src/domains/recipe/hook/useSearchControl.tsx b/src/domains/recipe/hook/useSearchControl.tsx deleted file mode 100644 index a00e40f1..00000000 --- a/src/domains/recipe/hook/useSearchControl.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { debounce } from '@/shared/utills/debounce'; -import { useEffect, useMemo, useState } from 'react'; - -interface UseSearchControlProps { - delay?: number; - storageKey?: string; // 검색 상태 저장용 키 -} - -function useSearchControl({ delay = 300, storageKey }: UseSearchControlProps) { - // 초기값을 sessionStorage에서 복원 - const [inputValue, setInputValue] = useState(() => { - if (typeof window === 'undefined' || !storageKey) return ''; - const saved = sessionStorage.getItem(`${storageKey}_search`); - return saved ? JSON.parse(saved).inputValue : ''; - }); - - const [keyword, setKeyword] = useState(() => { - if (typeof window === 'undefined' || !storageKey) return ''; - const saved = sessionStorage.getItem(`${storageKey}_search`); - return saved ? JSON.parse(saved).keyword : ''; - }); - - const [noResults, setNoResults] = useState(false); - - const isSearching = keyword.trim().length > 0; - - // 검색 상태를 sessionStorage에 저장 - useEffect(() => { - if (!storageKey) return; - sessionStorage.setItem( - `${storageKey}_search`, - JSON.stringify({ - inputValue, - keyword, - }) - ); - }, [inputValue, keyword, storageKey]); - - const debouncedKeyword = useMemo(() => debounce((v: string) => setKeyword(v), delay), [delay]); - - const onInputChange = (v: string) => { - setInputValue(v); - debouncedKeyword(v); - }; - - // 검색 상태 초기화 함수 - const resetSearch = () => { - setInputValue(''); - setKeyword(''); - setNoResults(false); - if (storageKey) { - sessionStorage.removeItem(`${storageKey}_search`); - } - }; - - return { - inputValue, - keyword, - isSearching, - onInputChange, - noResults, - setNoResults, - resetSearch, - }; -} - -export default useSearchControl; diff --git a/src/domains/recipe/types/types.ts b/src/domains/recipe/types/types.ts index b3a176f9..2d53040b 100644 --- a/src/domains/recipe/types/types.ts +++ b/src/domains/recipe/types/types.ts @@ -4,7 +4,7 @@ export interface Cocktail { cocktailName: string; cocktailImgUrl: string; cocktailNameKo: string; - isFavorited: boolean; + isKeep: boolean; } export interface RecommendCocktail { @@ -36,3 +36,4 @@ export type TagType = { cocktailName: string; cocktailNameKo: string; }; +export type Sort = 'recent' | 'keeps' | 'comments'; diff --git a/src/domains/shared/components/cocktail-card/CocktailCard.tsx b/src/domains/shared/components/cocktail-card/CocktailCard.tsx index b113c4fa..593e3fa5 100644 --- a/src/domains/shared/components/cocktail-card/CocktailCard.tsx +++ b/src/domains/shared/components/cocktail-card/CocktailCard.tsx @@ -33,7 +33,6 @@ function CocktailCard({ favor, }: Props) { const alcoholTitle = labelTitle(alcohol); - return (
( - targetRef: RefObject, // 관찰하는 요소 - onIntersect: IntersectionObserverCallback, // 관찰 될 때 실행할 함수 - hasNextPage: boolean | undefined // 무한스크롤로 더 불러올 요소가 있는지 -) => { - const observer = useRef(null); - - useEffect(() => { - if (targetRef && targetRef.current) { - observer.current = new IntersectionObserver(onIntersect, { - root: null, - rootMargin: '200px', - threshold: 1.0, - }); - if (!hasNextPage) { - observer.current?.unobserve(targetRef.current); - return; - } - observer.current.observe(targetRef.current); - } - return () => observer && observer.current?.disconnect(); - }, [targetRef, onIntersect]); -}; diff --git a/src/domains/shared/hook/useMemoScroll.ts b/src/domains/shared/hook/useMemoScroll.ts deleted file mode 100644 index 87bd13fd..00000000 --- a/src/domains/shared/hook/useMemoScroll.ts +++ /dev/null @@ -1,156 +0,0 @@ -// useScrollRestore.ts -import { usePathname } from 'next/navigation'; -import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; - -interface UseScrollRestoreProps { - lastId: number | null; // 현재까지 로드한 "최소 id"(내림차순에서 커서) - fetchData: (cursor?: string) => Promise; - currentDataLength: number; - hasNextPage?: boolean; // 선택: 있으면 조기 종료에 사용 -} - -type SavedShape = { targetId: number | null; scrollY: number }; - -export function useScrollRestore({ - lastId, - fetchData, - currentDataLength, - hasNextPage, -}: UseScrollRestoreProps) { - const pathname = usePathname(); - const KEY = `scroll-${pathname}`; - - const isRestoringRef = useRef(false); - const hasRestoredRef = useRef(false); - const lastIdRef = useRef(lastId); - const lenRef = useRef(currentDataLength); - - useEffect(() => { - lastIdRef.current = lastId; - }, [lastId]); - useEffect(() => { - lenRef.current = currentDataLength; - }, [currentDataLength]); - - // 브라우저 기본 복원 비활성화 - useLayoutEffect(() => { - if ('scrollRestoration' in history) { - try { - history.scrollRestoration = 'manual'; - } catch {} - } - }, []); - - const jumpOnce = useCallback( - (y: number) => { - const el = document.scrollingElement || document.documentElement; - const enough = () => document.body.scrollHeight >= y + window.innerHeight; - let done = false; - - const finish = () => { - if (done) return; - done = true; - el.scrollTo({ top: y, behavior: 'auto' }); - isRestoringRef.current = false; - hasRestoredRef.current = true; - sessionStorage.removeItem(KEY); - }; - - if (enough()) { - requestAnimationFrame(finish); - return; - } - - const ro = new ResizeObserver(() => { - if (enough()) { - ro.disconnect(); - finish(); - } - }); - ro.observe(document.body); - window.addEventListener( - 'load', - () => { - if (enough()) finish(); - }, - { once: true } - ); - setTimeout(() => finish(), 1000); - }, - [KEY] - ); - - // 복원 - useEffect(() => { - if (hasRestoredRef.current) return; - - const raw = sessionStorage.getItem(KEY); - if (!raw) { - hasRestoredRef.current = true; - return; - } - - let saved: SavedShape | null = null; - try { - saved = JSON.parse(raw) as SavedShape; - } catch { - sessionStorage.removeItem(KEY); - return; - } - if (!saved) { - sessionStorage.removeItem(KEY); - return; - } - - const { targetId, scrollY } = saved; - isRestoringRef.current = true; - - const MAX_FETCH = 50; - - const restore = async () => { - let tries = 0; - let lastProgressLen = lenRef.current; - let lastProgressId = lastIdRef.current; - - // 내림차순 전제: - // 더 불러올수록 현재 최소 id(=lastIdRef.current)가 "작아진다" - // 목표 지점 도달 조건: currentMinId <= targetId 또는 targetId==null - while ( - targetId != null && - (lastIdRef.current == null || (lastIdRef.current as number) > targetId) - ) { - if (hasNextPage === false) break; // 더 없음 - if (tries++ >= MAX_FETCH) break; // 안전망 - - await fetchData(); - - // 진행 없음(길이와 lastId 모두 동일) → 중단 - const noLenChange = lenRef.current === lastProgressLen; - const noIdChange = lastIdRef.current === lastProgressId; - if (noLenChange && noIdChange) break; - - lastProgressLen = lenRef.current; - lastProgressId = lastIdRef.current; - - // 다음 렌더로 넘겨 레이아웃 안정화 - await new Promise((r) => setTimeout(r, 0)); - } - - requestAnimationFrame(() => jumpOnce(scrollY)); - }; - - restore(); - }, [KEY, fetchData, hasNextPage, jumpOnce]); - - // 저장 - const saveScroll = useCallback(() => { - const payload: SavedShape = { - targetId: lastIdRef.current, - scrollY: window.scrollY, - }; - sessionStorage.setItem(KEY, JSON.stringify(payload)); - sessionStorage.setItem('saveUrl', location.href); - }, [KEY]); - - return saveScroll; -}