diff --git a/public/icons/new/back_icon.svg b/public/icons/new/back_icon.svg new file mode 100644 index 00000000..914a9994 --- /dev/null +++ b/public/icons/new/back_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/new/history.svg b/public/icons/new/history.svg new file mode 100644 index 00000000..8bde5006 --- /dev/null +++ b/public/icons/new/history.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/icons/new/vertical_kebab_button.svg b/public/icons/new/vertical_kebab_button.svg new file mode 100644 index 00000000..ac53dc62 --- /dev/null +++ b/public/icons/new/vertical_kebab_button.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons/ver3/bookmark.svg b/public/icons/ver3/bookmark.svg new file mode 100644 index 00000000..97d7a5c0 --- /dev/null +++ b/public/icons/ver3/bookmark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/icons/ver3/detail_share.svg b/public/icons/ver3/detail_share.svg new file mode 100644 index 00000000..5ae52f15 --- /dev/null +++ b/public/icons/ver3/detail_share.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/icons/ver3/lock.svg b/public/icons/ver3/lock.svg new file mode 100644 index 00000000..a1930d24 --- /dev/null +++ b/public/icons/ver3/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/ver3/more.svg b/public/icons/ver3/more.svg new file mode 100644 index 00000000..35abcbc1 --- /dev/null +++ b/public/icons/ver3/more.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/icons/ver3/reaction_agree.svg b/public/icons/ver3/reaction_agree.svg new file mode 100644 index 00000000..7e2c2a9c --- /dev/null +++ b/public/icons/ver3/reaction_agree.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons/ver3/reaction_good.svg b/public/icons/ver3/reaction_good.svg new file mode 100644 index 00000000..645d1daf --- /dev/null +++ b/public/icons/ver3/reaction_good.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons/ver3/reaction_thanks.svg b/public/icons/ver3/reaction_thanks.svg new file mode 100644 index 00000000..4ccc66f4 --- /dev/null +++ b/public/icons/ver3/reaction_thanks.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons/ver3/share.svg b/public/icons/ver3/share.svg index eb4d401e..2f20fb26 100644 --- a/public/icons/ver3/share.svg +++ b/public/icons/ver3/share.svg @@ -1,3 +1,3 @@ - - + + diff --git a/public/icons/ver3/visibility.svg b/public/icons/ver3/visibility.svg new file mode 100644 index 00000000..8ef339ac --- /dev/null +++ b/public/icons/ver3/visibility.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/_api/reaction/Reaction.ts b/src/app/_api/reaction/Reaction.ts new file mode 100644 index 00000000..c0b19f9f --- /dev/null +++ b/src/app/_api/reaction/Reaction.ts @@ -0,0 +1,8 @@ +import axiosInstance from '@/lib/axios/axiosInstance'; +import { ReactionType } from '@/lib/types/reactionType'; + +const reaction = async (listId: number, type: ReactionType) => { + return await axiosInstance.post(`/lists/${listId}/reaction`, { reaction: type }); +}; + +export default reaction; diff --git a/src/app/_api/search/getSearchListResult.ts b/src/app/_api/search/getSearchListResult.ts index 8dda406e..4694ee53 100644 --- a/src/app/_api/search/getSearchListResult.ts +++ b/src/app/_api/search/getSearchListResult.ts @@ -4,10 +4,10 @@ interface GetSearchListResultType { cursorId: number | undefined | null; sort: string; keyword: string; - category: string; + categoryCode: string; } -async function getSearchListResult({ sort, keyword, category, cursorId }: GetSearchListResultType) { +async function getSearchListResult({ sort, keyword, categoryCode, cursorId }: GetSearchListResultType) { const params = new URLSearchParams({ size: '6', }); @@ -17,7 +17,7 @@ async function getSearchListResult({ sort, keyword, category, cursorId }: GetSea } const response = await axiosInstance.get( - `/lists/search?keyword=${keyword}&sort=${sort}&category=${category}&${params.toString()}` + `/lists/search?keyword=${keyword}&sort=${sort}&categoryCode=${categoryCode}&${params.toString()}` ); return response.data; diff --git a/src/app/_api/search/getSearchUserResult.ts b/src/app/_api/search/getSearchUserResult.ts index bd0e593f..603d98b7 100644 --- a/src/app/_api/search/getSearchUserResult.ts +++ b/src/app/_api/search/getSearchUserResult.ts @@ -1,14 +1,21 @@ import axiosInstance from '@/lib/axios/axiosInstance'; interface GetSearchUserResultType { + // cursorId: number | undefined | null; + page: number | undefined | null; keyword: string; } -async function getSearchUserResult({ keyword }: GetSearchUserResultType) { +async function getSearchUserResult({ keyword, page }: GetSearchUserResultType) { + console.log('page:::', page); const params = new URLSearchParams({ size: '3', }); + if (page) { + params.append('page', page.toString()); + } + const response = await axiosInstance.get(`/users?search=${keyword}&${params.toString()}`); return response.data; diff --git a/src/app/list/[listId]/_components/ListDetailInner/CollectButton.css.ts b/src/app/list/[listId]/_components/ListDetailInner/CollectButton.css.ts index 9878d52c..f2ef5af4 100644 --- a/src/app/list/[listId]/_components/ListDetailInner/CollectButton.css.ts +++ b/src/app/list/[listId]/_components/ListDetailInner/CollectButton.css.ts @@ -5,6 +5,7 @@ export const myCollectWrapper = style({ flexDirection: 'column', justifyContent: 'center', alignItems: 'center', + gap: '6px', }); export const collectWrapper = style([ @@ -13,3 +14,10 @@ export const collectWrapper = style([ cursor: 'pointer', }, ]); + +export const collectTextWrapper = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', +}); diff --git a/src/app/list/[listId]/_components/ListDetailInner/CollectButton.tsx b/src/app/list/[listId]/_components/ListDetailInner/CollectButton.tsx index d5453896..32709b4e 100644 --- a/src/app/list/[listId]/_components/ListDetailInner/CollectButton.tsx +++ b/src/app/list/[listId]/_components/ListDetailInner/CollectButton.tsx @@ -8,8 +8,8 @@ import { useUser } from '@/store/useUser'; import numberFormatter from '@/lib/utils/numberFormatter'; import collectList from '@/app/_api/collect/__collectList'; import toasting from '@/lib/utils/toasting'; -import CollectIcon from '/public/icons/collect.svg'; -import CollectedIcon from '/public/icons/collected.svg'; +import BookmarkIcon from '/public/icons/ver3/bookmark.svg'; +import BookmarkedIcon from '/public/icons/collected.svg'; import Modal from '@/components/Modal/Modal'; import LoginModal from '@/components/login/LoginModal'; import useBooleanOutput from '@/hooks/useBooleanOutput'; @@ -74,7 +74,7 @@ const CollectButton = ({ data }: { data: CollectProps }) => { return ( <> - + {isOn && ( @@ -89,15 +89,22 @@ const CollectButton = ({ data }: { data: CollectProps }) => { if (loginUser?.id === data.ownerId) { return ( - - {numberFormatter(data.collectCount, 'ko') ?? 0} + + + + 콜렉트 + {loginUser?.id === data.ownerId && ({numberFormatter(data.collectCount, 'ko') ?? 0})} + ); } return ( - {data.isCollected ? : } + {data.isCollected ? : } + + 콜렉트 + ); }; diff --git a/src/app/list/[listId]/_components/ListDetailInner/Footer.css.ts b/src/app/list/[listId]/_components/ListDetailInner/Footer.css.ts index 9a9bf820..ea95b225 100644 --- a/src/app/list/[listId]/_components/ListDetailInner/Footer.css.ts +++ b/src/app/list/[listId]/_components/ListDetailInner/Footer.css.ts @@ -1,4 +1,5 @@ import { style } from '@vanilla-extract/css'; +import { vars } from '@/styles/theme.css'; export const container = style({ width: '100%', @@ -7,10 +8,11 @@ export const container = style({ justifyContent: 'space-between', alignItems: 'center', - padding: '1rem 4rem 2rem 4rem', + padding: '1rem 0', }); export const collectAndView = style({ + width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', @@ -23,12 +25,17 @@ export const shareAndOthers = style({ display: 'flex', flexDirection: 'row', justifyContent: 'right', - alignItems: 'center', + alignItems: 'flex-start', gap: '20px', }); export const buttonComponent = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', cursor: 'pointer', + gap: '6px', }); // TODO: 조회수 증가 기능이 완료되면 display: 'flex' 로 수정 예정 @@ -41,3 +48,30 @@ export const viewCountWrapper = style({ cursor: 'pointer', }); + +export const reactionContainer = style({ + padding: '8px 12px', + display: 'flex', + gap: '8px', + background: vars.color.bggray, + borderRadius: '16px', +}); + +export const reactionText = style({ + minWidth: '40px', + textAlign: 'center', +}); + +export const reactionIcon = style({ + transition: 'filter 0.3s ease', +}); + +export const reactionIconInactive = style({ + filter: 'grayscale(100%)', +}); + +export const reactionIconHover = style({ + ':hover': { + filter: 'grayscale(0%)', + }, +}); diff --git a/src/app/list/[listId]/_components/ListDetailInner/Footer.tsx b/src/app/list/[listId]/_components/ListDetailInner/Footer.tsx index 76f20fcb..ec776445 100644 --- a/src/app/list/[listId]/_components/ListDetailInner/Footer.tsx +++ b/src/app/list/[listId]/_components/ListDetailInner/Footer.tsx @@ -1,13 +1,13 @@ 'use client'; -import { MouseEvent, useState } from 'react'; +import { MouseEvent, useMemo, useState } from 'react'; import { usePathname, useRouter } from 'next/navigation'; import Script from 'next/script'; import * as styles from './Footer.css'; import { useUser } from '@/store/useUser'; import { useLanguage } from '@/store/useLanguage'; -import { ItemType } from '@/lib/types/listType'; +import { ItemType, Reaction } from '@/lib/types/listType'; import { UserProfileType } from '@/lib/types/userProfileType'; import toasting from '@/lib/utils/toasting'; import BottomSheet from '@/components/BottomSheet/BottomSheet'; @@ -15,12 +15,20 @@ import ModalPortal from '@/components/modal-portal'; import { listLocale } from '@/app/list/[listId]/locale'; import CollectButton from '@/app/list/[listId]/_components/ListDetailInner/CollectButton'; import getBottomSheetOptionList from '@/app/list/[listId]/_components/ListDetailInner/getBottomSheetOptionList'; -import ShareIcon from '/public/icons/share.svg'; -import EtcIcon from '/public/icons/etc.svg'; +import ShareIcon from '/public/icons/ver3/share.svg'; +import MoreIcon from '/public/icons/ver3/more.svg'; import EyeIcon from '/public/icons/eye.svg'; import Modal from '@/components/Modal/Modal'; import LoginModal from '@/components/login/LoginModal'; import useBooleanOutput from '@/hooks/useBooleanOutput'; +import ReactionAgreeIcon from '/public/icons/ver3/reaction_agree.svg'; +import ReactionGoodIcon from '/public/icons/ver3/reaction_good.svg'; +import ReactionThanksIcon from '/public/icons/ver3/reaction_thanks.svg'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { QUERY_KEYS } from '@/lib/constants/queryKeys'; +import { AxiosError } from 'axios'; +import reaction from '@/app/_api/reaction/Reaction'; +import { ReactionType } from '@/lib/types/reactionType'; interface BottomSheetOptionsProps { key: string; @@ -41,6 +49,7 @@ interface FooterProps { viewCount: number; collectCount: number; isPublic: boolean; + reactions: Reaction[]; } declare global { @@ -49,6 +58,32 @@ declare global { } } +const ReactionIcon = ({ type, ...props }: { type: ReactionType } & React.SVGProps) => { + switch (type) { + case 'COOL': + return ; + case 'AGREE': + return ; + case 'THANKS': + return ; + default: + return null; + } +}; + +const getReactionText = (type: ReactionType, language: 'ko' | 'en') => { + switch (type) { + case 'COOL': + return language === 'ko' ? '멋져요' : 'Cool'; + case 'AGREE': + return language === 'ko' ? '동의해요' : 'Agree'; + case 'THANKS': + return language === 'ko' ? '고마워요' : 'Thanks'; + default: + return ''; + } +}; + function Footer({ data }: { data: FooterProps }) { const { language } = useLanguage(); const router = useRouter(); @@ -58,6 +93,8 @@ function Footer({ data }: { data: FooterProps }) { const [isSheetActive, setSheetActive] = useState(false); const [sheetOptionList, setSheetOptionList] = useState([]); const listUrl = `https://listywave.com${path}`; + const queryClient = useQueryClient(); + // const [localReactions, setLocalReactions] = useState(data.reactions); function kakaoInit() { if (!window.Kakao.isInitialized()) { @@ -68,6 +105,60 @@ function Footer({ data }: { data: FooterProps }) { let goToCreateList: () => void; + const reactions = useMemo(() => { + return { + COOL: data.reactions.find((r) => r.reaction === 'COOL') || { count: 0, isReacted: false }, + AGREE: data.reactions.find((r) => r.reaction === 'AGREE') || { count: 0, isReacted: false }, + THANKS: data.reactions.find((r) => r.reaction === 'THANKS') || { count: 0, isReacted: false }, + }; + }, [data.reactions]); + + // TODO: 현재 새로굄해야만 반영되는 버그 있음. 낙관적업데이트도 되지않고있음. 수정필요. + const reactMutation = useMutation({ + mutationKey: [QUERY_KEYS.reaction, data.listId], + mutationFn: ({ type }: { type: ReactionType }) => reaction(data.listId, type), + onMutate: async ({ type }) => { + await queryClient.cancelQueries({ queryKey: [QUERY_KEYS.getListDetail, data.listId] }); + const previousList = queryClient.getQueryData([QUERY_KEYS.getListDetail, data.listId]); + + if (!previousList) return { previousList: null }; + + const updatedReactions = previousList.reactions.map((reaction) => { + if (reaction.reaction === type) { + return { + ...reaction, + count: reaction.isReacted ? Math.max((reaction.count || 1) - 1, 0) : (reaction.count || 0) + 1, + isReacted: !reaction.isReacted, + }; + } + return reaction; + }); + + const updatedList = { + ...previousList, + reactions: updatedReactions, + }; + + queryClient.setQueryData([QUERY_KEYS.getListDetail, data.listId], updatedList); + + return { previousList }; + }, + onError: (error: AxiosError, variables, context) => { + if (error.response?.status === 401) { + handleSetOn(); + } + if (context?.previousList) { + queryClient.setQueryData([QUERY_KEYS.getListDetail, data.listId], context.previousList); + } + }, + onSettled: () => { + console.log('reaction settled'); + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.getListDetail, data.listId], + }); + }, + }); + if (loginUser.id === null) { goToCreateList = () => { toasting({ type: 'default', txt: listLocale[language].loginRequired }); @@ -110,6 +201,14 @@ function Footer({ data }: { data: FooterProps }) { ); }; + const handleReaction = (type: 'COOL' | 'AGREE' | 'THANKS') => { + if (!loginUser.id) { + handleSetOn(); // 로그인 모달 표시 + return; + } + reactMutation.mutate({ type }); + }; + return ( <>
콜렉트
({numberFormatter(data.collectCount, 'ko') ?? 0})